From c0e540ee7688b16eda7c00dd11f7d82f2cefaf0c Mon Sep 17 00:00:00 2001 From: hermes Date: Tue, 28 Jul 2026 19:51:32 +0000 Subject: [PATCH 1/2] feat: implement Ghidra adapter and Nix integration --- .forgejo/workflows/ci.yml | 14 + .gitignore | 8 +- README.md | 69 +- deny.toml | 26 + fixtures/README.md | 14 + fixtures/src/known.c | 18 + flake.lock | 27 + flake.nix | 271 +++++++ java/GhidrAdapter.java | 905 ++++++++++++++++++++++++ java/dispatch.txt | 8 + tests/adapter_contract.rs | 48 ++ tests/data/adapter-doctor-request.json | 19 + tests/data/adapter-inspect-request.json | 19 + tests/real_ghidra_adapter.sh | 50 ++ 14 files changed, 1482 insertions(+), 14 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 deny.toml create mode 100644 fixtures/README.md create mode 100644 fixtures/src/known.c create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 java/GhidrAdapter.java create mode 100644 java/dispatch.txt create mode 100644 tests/adapter_contract.rs create mode 100644 tests/data/adapter-doctor-request.json create mode 100644 tests/data/adapter-inspect-request.json create mode 100644 tests/real_ghidra_adapter.sh diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..1e2a6fb --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,14 @@ +name: checks + +on: + push: + pull_request: + +jobs: + flake: + runs-on: x86_64-linux + steps: + - uses: actions/checkout@v4 + - run: nix flake check --print-build-logs + - run: nix build .#packages.x86_64-linux.default --print-build-logs + - run: nix run . -- doctor diff --git a/.gitignore b/.gitignore index babfde6..5cedfba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -/target/ -*.log +/result +/result-* +/target *.gpr *.rep/ -.direnv/ -.envrc - diff --git a/README.md b/README.md index a4092d6..4102142 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ A small, dependable command-line interface for read-only Ghidra analysis. The repository is named `ghidra-cli`; the installed executable is `ghidr`. -The project now has its version 0.1 Rust contract foundation. The typed CLI, -public envelopes and schemas, bounded Rust/Java protocol, and synchronous -execution seam are present; store, process/sandbox, and Ghidra adapter execution -remain deliberately unconnected. +The project has a version 0.1 Rust contract foundation plus a strict Java +headless adapter, reproducible native fixtures, and pinned x86-64 Linux Nix +packaging. Store and process/sandbox integration is developed independently +behind the existing synchronous executor boundary. Proposed usage: @@ -32,12 +32,63 @@ available explicitly through `--format human`. including the Ghidra MCP comparison that informed protocol safeguards and the post-version-0.1 roadmap. -## Status +## Pinned package -Foundation only. Every accepted command currently returns a typed -`internal_not_implemented` runtime error instead of pretending Ghidra analysis -succeeded. Subsequent layers implement the library's synchronous executor -contract while preserving the committed schemas and reviewed fixtures. +The flake fixes Ghidra at exactly 12.1.2 and uses JDK 21. It also closes over +Rust, Bubblewrap, the Java adapter, deterministic Java/Nix formatters, +`cargo-deny`, and `statix`: + +```console +nix run . -- doctor +nix develop +``` + +The native fixtures are reproducible Nix outputs rather than committed opaque +binaries: + +```console +nix build .#fixtures +file result/elf-x86_64/known.elf result/pe32plus-x86_64/known.exe +``` + +## Adapter boundary + +The Ghidra worker dispatches `doctor`, `inspect`, `functions`, and `decompile`. +`inspect` is the import/analyze vertical slice: the trusted harness stages the +Sample and invokes official `analyzeHeadless`; the post-script reads only the +current imported Program and private request/response paths. `clean` is in the +five-operation Rust registry but is intentionally rejected by Java because it +is a trusted, atomic Analysis Store transaction. This boundary is enforced by +`tests/adapter_contract.rs` and documented in `java/dispatch.txt`. + +The adapter accepts at most 1 MiB of request JSON before parsing, rejects +missing/unknown fields, echoes protocol version/invocation/operation, and +publishes at most 256 MiB through a temporary file plus same-filesystem atomic +rename. It has no socket, network client, listener, script execution, mutation, +or user-selected destination surface. + +## Checks + +The complete pinned suite is: + +```console +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +cargo deny check +nix flake check --print-build-logs +``` + +`nix flake check` compiles Java with JDK 21 `-Xlint:all -Werror`, checks +Google-style `clang-format`, `nixfmt`, and `statix`, builds both fixtures, runs the +adapter against real Ghidra 12.1.2, and exercises `inspect`, `functions`, and +`decompile` through the packaged `ghidr` path. Individual real-engine checks +remain directly runnable as: + +```console +nix build .#checks.x86_64-linux.java-adapter-e2e --print-build-logs +nix build .#checks.x86_64-linux.real-ghidra-e2e --print-build-logs +``` ## License diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..cd89eb4 --- /dev/null +++ b/deny.toml @@ -0,0 +1,26 @@ +[advisories] +version = 2 +yanked = "deny" + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" + +[licenses] +version = 2 +confidence-threshold = 0.93 +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "Unicode-3.0", + "Zlib", +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..a793af9 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,14 @@ +# Reproducible native fixtures + +`fixtures/src/known.c` is compiled by Nix into two tiny, dependency-free test +programs from the same reviewed source: + +- `elf-x86_64/known.elf`: static ELF, x86-64 little-endian; +- `pe32plus-x86_64/known.exe`: PE32+, x86-64. + +Both builds disable timestamps and build IDs, set `SOURCE_DATE_EPOCH=1`, avoid +host libraries, and are stripped only of non-semantic comments/notes. Build +them with `nix build .#fixtures`. Version 0.1 intentionally does not claim an +unverified third target until that target is exercised by the real-Ghidra +suite; recognizing a file format without a maintained cross-toolchain test is +not sufficient evidence. diff --git a/fixtures/src/known.c b/fixtures/src/known.c new file mode 100644 index 0000000..f8c3434 --- /dev/null +++ b/fixtures/src/known.c @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: CC0-1.0 + +volatile unsigned long fixture_marker = 7; + +__attribute__((noinline)) unsigned long fixture_add(unsigned long value) { + return value + fixture_marker; +} + +void _start(void) { + (void)fixture_add(35); + for (;;) { + __asm__ volatile("" ::: "memory"); + } +} + +void mainCRTStartup(void) { + _start(); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bbe337e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1752480373, + "narHash": "sha256-JHQbm+OcGp32wAsXTE/FLYGNpb+4GLi5oTvCxwSoBOA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "62e0f05ede1da0d54515d4ea8ce9c733f12d9f08", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..abab516 --- /dev/null +++ b/flake.nix @@ -0,0 +1,271 @@ +{ + description = "ghidr 0.1: pinned read-only Ghidra analysis CLI"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/62e0f05ede1da0d54515d4ea8ce9c733f12d9f08"; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) lib; + jdk = pkgs.jdk21_headless; + + ghidra = pkgs.stdenvNoCC.mkDerivation { + pname = "ghidra"; + version = "12.1.2"; + src = pkgs.fetchurl { + url = "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.2_build/ghidra_12.1.2_PUBLIC_20260605.zip"; + hash = "sha256-ti6BoDkGGEZsAZxg2ML3ls7SUJxMGupKN2RKdycs+Z0="; + }; + nativeBuildInputs = [ + pkgs.makeWrapper + pkgs.unzip + ]; + dontConfigure = true; + dontBuild = true; + unpackPhase = '' + runHook preUnpack + unzip -q "$src" + sourceRoot=ghidra_12.1.2_PUBLIC + runHook postUnpack + ''; + installPhase = '' + runHook preInstall + mkdir -p "$out/lib/ghidra" "$out/bin" + cp -a . "$out/lib/ghidra/" + makeWrapper "$out/lib/ghidra/support/analyzeHeadless" "$out/bin/ghidra-analyzeHeadless" \ + --set JAVA_HOME "${jdk}" \ + --prefix PATH : "${lib.makeBinPath [ jdk ]}" + runHook postInstall + ''; + passthru = { inherit jdk; }; + meta = { + description = "Ghidra 12.1.2 headless analysis distribution"; + homepage = "https://github.com/NationalSecurityAgency/ghidra"; + license = lib.licenses.asl20; + platforms = [ system ]; + sourceProvenance = [ lib.sourceTypes.binaryBytecode ]; + }; + }; + + adapter = pkgs.stdenvNoCC.mkDerivation { + pname = "ghidr-java-adapter"; + version = "0.1.0"; + src = ./java; + nativeBuildInputs = [ + jdk + pkgs.clang-tools + ]; + dontConfigure = true; + buildPhase = '' + runHook preBuild + clang-format --dry-run --Werror --style=Google GhidrAdapter.java + classpath="$(find ${ghidra}/lib/ghidra -type f -name '*.jar' -print | LC_ALL=C sort | paste -sd: -)" + javac -encoding UTF-8 -source 21 -target 21 -Xlint:all -Werror -cp "$classpath" GhidrAdapter.java + runHook postBuild + ''; + installPhase = '' + runHook preInstall + mkdir -p "$out/share/ghidr/java" + install -m 0444 GhidrAdapter.java dispatch.txt "$out/share/ghidr/java/" + install -m 0444 ./*.class "$out/share/ghidr/java/" + runHook postInstall + ''; + }; + + elfFixture = pkgs.stdenv.mkDerivation { + pname = "ghidr-fixture-elf-x86-64"; + version = "0.1.0"; + src = ./fixtures/src/known.c; + dontUnpack = true; + SOURCE_DATE_EPOCH = "1"; + buildPhase = '' + runHook preBuild + $CC -x c "$src" -Os -ffreestanding -fno-ident -fno-stack-protector \ + -fno-asynchronous-unwind-tables -fno-unwind-tables -nostdlib -static \ + -no-pie -Wl,--build-id=none,-e,_start -o known.elf + runHook postBuild + ''; + installPhase = '' + mkdir -p "$out/elf-x86_64" + install -m 0555 known.elf "$out/elf-x86_64/known.elf" + ''; + }; + + peFixture = pkgs.pkgsCross.mingwW64.stdenv.mkDerivation { + pname = "ghidr-fixture-pe32plus-x86-64"; + version = "0.1.0"; + src = ./fixtures/src/known.c; + dontUnpack = true; + SOURCE_DATE_EPOCH = "1"; + buildPhase = '' + runHook preBuild + $CC -x c "$src" -Os -ffreestanding -fno-ident -fno-stack-protector \ + -nostdlib -Wl,--no-insert-timestamp,--entry,mainCRTStartup,--subsystem,console \ + -o known.exe + runHook postBuild + ''; + installPhase = '' + mkdir -p "$out/pe32plus-x86_64" + install -m 0555 known.exe "$out/pe32plus-x86_64/known.exe" + ''; + }; + + fixtures = pkgs.symlinkJoin { + name = "ghidr-fixtures-0.1.0"; + paths = [ + elfFixture + peFixture + ]; + }; + + rustPackage = pkgs.rustPlatform.buildRustPackage { + pname = "ghidra-cli"; + version = "0.1.0"; + src = lib.cleanSource self; + cargoLock.lockFile = ./Cargo.lock; + doCheck = true; + nativeBuildInputs = [ pkgs.installShellFiles ]; + }; + + ghidr = + pkgs.runCommand "ghidr-0.1.0" + { + nativeBuildInputs = [ pkgs.makeWrapper ]; + meta.mainProgram = "ghidr"; + } + '' + mkdir -p "$out/bin" "$out/share/ghidr" + ln -s ${adapter}/share/ghidr/java "$out/share/ghidr/java" + makeWrapper ${rustPackage}/bin/ghidr "$out/bin/ghidr" \ + --set GHIDR_GHIDRA_VERSION 12.1.2 \ + --set GHIDR_JAVA_HOME ${jdk} \ + --set GHIDR_ANALYZE_HEADLESS ${ghidra}/bin/ghidra-analyzeHeadless \ + --set GHIDR_ADAPTER_PATH "$out/share/ghidr/java" \ + --prefix PATH : ${ + lib.makeBinPath [ + ghidra + jdk + pkgs.bubblewrap + ] + } + ''; + + realGhidraE2e = + pkgs.runCommand "ghidr-real-ghidra-e2e" + { + nativeBuildInputs = [ + ghidr + pkgs.jq + ]; + } + '' + export HOME="$TMPDIR/home" + mkdir -p "$HOME" "$TMPDIR/store" + ${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" inspect \ + ${fixtures}/elf-x86_64/known.elf > elf.json + jq -e '.kind == "inspection" and .provenance.ghidra_version == "12.1.2"' elf.json + ${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" functions \ + ${fixtures}/pe32plus-x86_64/known.exe --all > pe.json + jq -e '.kind == "functions" and (.data.items | length) > 0' pe.json + ${ghidr}/bin/ghidr --sandbox off --store "$TMPDIR/store" decompile \ + ${fixtures}/elf-x86_64/known.elf --name fixture_add > decompile.json + jq -e '.data.function.name == "fixture_add"' decompile.json + touch "$out" + ''; + + adapterE2e = + pkgs.runCommand "ghidr-java-adapter-e2e" + { + nativeBuildInputs = [ + ghidra + jdk + pkgs.jq + ]; + } + '' + export HOME="$TMPDIR/home" + export GHIDR_ANALYZE_HEADLESS=${ghidra}/bin/ghidra-analyzeHeadless + export GHIDR_ADAPTER_PATH=${adapter}/share/ghidr/java + export GHIDR_FIXTURES=${fixtures} + mkdir -p "$HOME" + cp -R ${./tests} tests + chmod +x tests/real_ghidra_adapter.sh + ./tests/real_ghidra_adapter.sh + touch "$out" + ''; + in + { + packages.${system} = { + default = ghidr; + inherit + ghidr + ghidra + adapter + fixtures + ; + real-ghidra-e2e = realGhidraE2e; + }; + + apps.${system}.default = { + type = "app"; + program = "${ghidr}/bin/ghidr"; + }; + + checks.${system} = { + rust = rustPackage; + java-adapter = adapter; + java-adapter-e2e = adapterE2e; + inherit fixtures; + real-ghidra-e2e = realGhidraE2e; + cargo-deny = + pkgs.runCommand "ghidr-cargo-deny" + { + nativeBuildInputs = [ pkgs.cargo-deny ]; + } + '' + cp -R ${lib.cleanSource self} source + chmod -R u+w source + cd source + cargo deny check bans licenses sources + touch "$out" + ''; + nix-style = + pkgs.runCommand "ghidr-nix-style" + { + nativeBuildInputs = [ + pkgs.nixfmt-rfc-style + pkgs.statix + ]; + } + '' + cp ${./flake.nix} flake.nix + nixfmt --check flake.nix + statix check flake.nix + touch "$out" + ''; + }; + + devShells.${system}.default = pkgs.mkShell { + packages = [ + pkgs.rustc + pkgs.cargo + pkgs.clippy + pkgs.rustfmt + pkgs.cargo-deny + pkgs.bubblewrap + pkgs.clang-tools + pkgs.nixfmt-rfc-style + pkgs.statix + pkgs.jq + jdk + ghidra + ]; + GHIDR_GHIDRA_VERSION = "12.1.2"; + GHIDR_JAVA_HOME = "${jdk}"; + GHIDR_ANALYZE_HEADLESS = "${ghidra}/bin/ghidra-analyzeHeadless"; + GHIDR_ADAPTER_PATH = "${adapter}/share/ghidr/java"; + }; + }; +} diff --git a/java/GhidrAdapter.java b/java/GhidrAdapter.java new file mode 100644 index 0000000..314f5c8 --- /dev/null +++ b/java/GhidrAdapter.java @@ -0,0 +1,905 @@ +// SPDX-License-Identifier: Apache-2.0 +// Ghidra headless post-script for ghidra-cli. This file intentionally has no package. + +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.decompiler.DecompileResults; +import ghidra.app.script.GhidraScript; +import ghidra.framework.Application; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressFactory; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Strict, bounded, read-only adapter used only by the ghidr harness. */ +public final class GhidrAdapter extends GhidraScript { + static final long PROTOCOL_VERSION = 1; + static final int MAX_REQUEST_BYTES = 1_048_576; + static final long MAX_RESPONSE_BYTES = 268_435_456L; + static final Set DISPATCH = Set.of("doctor", "inspect", "functions", "decompile"); + + @Override + protected void run() throws Exception { + String[] arguments = getScriptArgs(); + if (arguments.length != 2) { + throw new AdapterException( + "protocol_violation", "adapter requires request and response paths"); + } + Path requestPath = Path.of(arguments[0]); + Path responsePath = Path.of(arguments[1]); + if (!requestPath.isAbsolute() || !responsePath.isAbsolute()) { + throw new AdapterException("protocol_violation", "adapter paths must be absolute"); + } + Path temporaryPath = responsePath.resolveSibling(responsePath.getFileName() + ".tmp"); + Request request = null; + try { + request = parseRequest(readBounded(requestPath)); + Object data = dispatch(request); + writeResponse(temporaryPath, responsePath, response(request, "success", data, null)); + } catch (ResponseTooLargeException error) { + Files.deleteIfExists(temporaryPath); + if (request == null) { + throw error; + } + Map details = object(); + details.put("limit_bytes", MAX_RESPONSE_BYTES); + details.put("observed_at_least_bytes", error.observedAtLeast); + details.put("operation", request.operation); + writeResponse(temporaryPath, responsePath, + response(request, "error", null, + new AdapterError("result_too_large", "adapter result exceeds 256 MiB", details))); + } catch (AdapterException error) { + Files.deleteIfExists(temporaryPath); + if (request == null) { + throw error; + } + writeResponse(temporaryPath, responsePath, + response(request, "error", null, + new AdapterError(error.code, error.getMessage(), error.details))); + } + } + + private Object dispatch(Request request) throws Exception { + return switch (request.operation) { + case "doctor" -> doctor(request.arguments); + case "inspect" -> inspect(request.arguments); + case "functions" -> functions(request.arguments); + case "decompile" -> decompile(request.arguments, request.decompileTimeoutSeconds); + default -> + throw new AdapterException("protocol_violation", "operation is not dispatched by Java"); + }; + } + + private Object doctor(Map arguments) throws AdapterException { + expectKeys(arguments, Set.of("kind"), "doctor arguments"); + requireKind(arguments, "doctor"); + Map result = object(); + result.put("ready", true); + result.put("ghidra_version", Application.getApplicationVersion()); + result.put("java_version", Runtime.version().feature()); + result.put("adapter_protocol_version", PROTOCOL_VERSION); + return result; + } + + private Object inspect(Map arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind"), "inspect arguments"); + requireKind(arguments, "inspect"); + Map program = object(); + program.put("image_base", address(currentProgram.getImageBase())); + program.put("minimum_address", address(currentProgram.getMinAddress())); + program.put("maximum_address", address(currentProgram.getMaxAddress())); + program.put("function_count", allFunctions().size()); + Map result = object(); + result.put("program", program); + result.put("target", target()); + result.put("ghidra_version", Application.getApplicationVersion()); + result.put("java_version", Runtime.version().feature()); + return result; + } + + private Object functions(Map arguments) throws AdapterException { + expectProgram(); + expectKeys(arguments, Set.of("kind", "page"), "functions arguments"); + requireKind(arguments, "functions"); + Map pageRequest = asObject(arguments.get("page"), "page"); + expectKeys(pageRequest, Set.of("offset", "limit"), "page"); + long offset = nonNegativeLong(pageRequest.get("offset"), "offset"); + Object limitValue = pageRequest.get("limit"); + Long limit = parseLimit(limitValue); + + List all = allFunctions(); + long total = all.size(); + int start = (int) Math.min(offset, total); + long requestedEnd = limit == null ? total : Math.min(total, saturatingAdd(offset, limit)); + int end = (int) Math.max(start, requestedEnd); + List items = new ArrayList<>(); + for (Function function : all.subList(start, end)) { + items.add(functionItem(function)); + } + Map page = object(); + page.put("order", "location_then_entry_ascending"); + page.put("offset", offset); + page.put("limit", limit); + page.put("returned", items.size()); + page.put("total", total); + page.put("has_more", end < total); + Map result = object(); + result.put("page", page); + result.put("items", items); + return result; + } + + private Object decompile(Map arguments, long timeoutSeconds) throws Exception { + expectProgram(); + expectKeys(arguments, Set.of("kind", "selector"), "decompile arguments"); + requireKind(arguments, "decompile"); + Map selector = asObject(arguments.get("selector"), "selector"); + expectKeys(selector, Set.of("kind", "value"), "selector"); + String kind = string(selector.get("kind"), "selector kind"); + Function function; + if (kind.equals("name")) { + String value = string(selector.get("value"), "selector value"); + function = resolveName(value); + } else if (kind.equals("address")) { + function = resolveAddress(asObject(selector.get("value"), "selector address")); + } else { + throw new AdapterException("protocol_violation", "unknown Function Selector kind"); + } + if (function.isExternal()) { + throw new AdapterException( + "function_not_decompilable", "external Function cannot be decompiled"); + } + + String text; + DecompInterface decompiler = new DecompInterface(); + try { + if (!decompiler.openProgram(currentProgram)) { + throw new AdapterException( + "decompilation_failed", "Ghidra decompiler did not open Program"); + } + int seconds = (int) Math.min(timeoutSeconds, Integer.MAX_VALUE); + DecompileResults results = decompiler.decompileFunction(function, seconds, monitor); + if (!results.decompileCompleted()) { + String message = results.getErrorMessage(); + if (message != null && message.toLowerCase(java.util.Locale.ROOT).contains("timeout")) { + throw new AdapterException("decompilation_timeout", "Ghidra decompilation timed out"); + } + throw new AdapterException("decompilation_failed", + message == null || message.isBlank() ? "Ghidra decompilation failed" : message); + } + text = normalizeLineEndings(results.getDecompiledFunction().getC()); + } finally { + decompiler.dispose(); + } + Map selected = object(); + selected.put("name", function.getName(false)); + selected.put("qualified_name", function.getName(true)); + selected.put("entry", address(function.getEntryPoint())); + Map decompilation = object(); + decompilation.put("syntax", "ghidra_c"); + decompilation.put("text", text); + Map result = object(); + result.put("requested_selector", selector); + result.put("function", selected); + result.put("decompilation", decompilation); + return result; + } + + private Function resolveName(String requested) throws AdapterException { + boolean qualified = requested.contains("::"); + List matches = new ArrayList<>(); + for (Function function : allFunctions()) { + String candidate = qualified ? function.getName(true) : function.getName(false); + if (candidate.equals(requested)) { + matches.add(function); + } + } + if (matches.isEmpty()) { + throw new AdapterException("function_not_found", "Function Selector did not resolve"); + } + if (matches.size() != 1) { + Map details = object(); + List candidates = new ArrayList<>(); + for (Function function : matches.subList(0, Math.min(100, matches.size()))) { + candidates.add(address(function.getEntryPoint())); + } + details.put("candidates", candidates); + details.put("candidate_count", matches.size()); + throw new AdapterException("function_selector_ambiguous", + "Function Selector resolves to more than one Function", details); + } + return matches.get(0); + } + + private Function resolveAddress(Map selector) throws AdapterException { + expectKeys(selector, Set.of("space", "offset"), "selector address"); + String spaceName = string(selector.get("space"), "address space"); + String offsetText = string(selector.get("offset"), "address offset"); + AddressFactory factory = currentProgram.getAddressFactory(); + AddressSpace selectedSpace = null; + List spaces = new ArrayList<>(); + for (AddressSpace space : factory.getAllAddressSpaces()) { + spaces.add(space.getName()); + if (space.getName().equals(spaceName)) { + selectedSpace = space; + } + } + if (selectedSpace == null) { + spaces.sort(GhidrAdapter::compareUtf8); + Map details = object(); + details.put("valid_address_spaces", spaces.subList(0, Math.min(100, spaces.size()))); + throw new AdapterException( + "address_space_not_found", "address space does not exist", details); + } + int width = Math.max(2, (selectedSpace.getSize() + 3) / 4); + if (!offsetText.matches("0x[0-9a-f]+") || offsetText.length() != width + 2) { + throw new AdapterException( + "protocol_violation", "address offset is not canonical for its space"); + } + BigInteger offset = new BigInteger(offsetText.substring(2), 16); + if (offset.bitLength() > 64) { + throw new AdapterException( + "protocol_violation", "address offset exceeds adapter representation"); + } + Address requested; + try { + requested = selectedSpace.getAddress(offset.longValue()); + } catch (RuntimeException error) { + throw new AdapterException("function_not_found", "address is outside its address space"); + } + Function exact = currentProgram.getFunctionManager().getFunctionAt(requested); + if (exact != null) { + return exact; + } + Function containing = currentProgram.getFunctionManager().getFunctionContaining(requested); + if (containing != null) { + Map details = object(); + details.put("containing_function_entry", address(containing.getEntryPoint())); + throw new AdapterException( + "function_entry_required", "address is inside a Function but is not its entry", details); + } + throw new AdapterException( + "function_not_found", "no Function exists at the requested entry Address"); + } + + private List allFunctions() { + LinkedHashSet unique = new LinkedHashSet<>(); + FunctionIterator memory = currentProgram.getFunctionManager().getFunctions(true); + while (memory.hasNext()) { + unique.add(memory.next()); + } + FunctionIterator external = currentProgram.getFunctionManager().getExternalFunctions(); + while (external.hasNext()) { + unique.add(external.next()); + } + List functions = new ArrayList<>(unique); + functions.sort(Comparator.comparingInt((Function function) -> function.isExternal() ? 1 : 0) + .thenComparing(function + -> function.getEntryPoint().getAddressSpace().getName(), + GhidrAdapter::compareUtf8) + .thenComparing( + function -> unsigned(function.getEntryPoint().getOffset()), BigInteger::compareTo)); + return functions; + } + + private Map functionItem(Function function) { + Map item = object(); + item.put("name", function.getName(false)); + item.put("qualified_name", function.getName(true)); + item.put("entry", address(function.getEntryPoint())); + item.put("body_address_count", function.getBody().getNumAddresses()); + item.put("location", function.isExternal() ? "external" : "memory"); + item.put("is_external", function.isExternal()); + item.put("is_thunk", function.isThunk()); + Function thunk = function.getThunkedFunction(false); + item.put("thunk_target_entry", thunk == null ? null : address(thunk.getEntryPoint())); + item.put("decompilable", !function.isExternal()); + return item; + } + + private Map target() { + Map target = object(); + target.put("loader", currentProgram.getExecutableFormat()); + target.put("format", currentProgram.getExecutableFormat()); + target.put("processor_language", currentProgram.getLanguageID().getIdAsString()); + target.put("compiler_specification", + currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString()); + return target; + } + + private static Map address(Address value) { + if (value == null || value == Address.NO_ADDRESS) { + return null; + } + AddressSpace space = value.getAddressSpace(); + int width = Math.max(2, (space.getSize() + 3) / 4); + String digits = Long.toUnsignedString(value.getOffset(), 16); + Map result = object(); + result.put("space", space.getName()); + result.put("offset", + "0x" + + "0".repeat(Math.max(0, width - digits.length())) + digits); + return result; + } + + private static Request parseRequest(byte[] bytes) throws AdapterException { + Object parsed = new JsonParser(decodeUtf8(bytes)).parse(); + Map root = asObject(parsed, "request"); + expectKeys(root, + Set.of("protocol_version", "invocation_id", "operation", "staged_sample", "analysis_path", + "limits", "arguments"), + "request"); + long version = nonNegativeLong(root.get("protocol_version"), "protocol_version"); + if (version != PROTOCOL_VERSION) { + throw new AdapterException("protocol_violation", "unsupported adapter protocol version"); + } + String invocation = string(root.get("invocation_id"), "invocation_id"); + if (!invocation.matches("[0-9a-f]{32}")) { + throw new AdapterException("protocol_violation", "invalid invocation ID"); + } + String operation = string(root.get("operation"), "operation"); + if (!DISPATCH.contains(operation)) { + throw new AdapterException("protocol_violation", "operation is not in Java dispatch set"); + } + nullableAbsolutePath(root.get("staged_sample"), "staged_sample"); + nullableAbsolutePath(root.get("analysis_path"), "analysis_path"); + Map limits = asObject(root.get("limits"), "limits"); + expectKeys(limits, + Set.of("max_heap_mib", "max_cpu", "analysis_timeout_seconds", "decompile_timeout_seconds", + "child_watchdog_seconds", "max_sample_bytes", "max_inline_bytes"), + "limits"); + positiveLong(limits.get("max_heap_mib"), "max_heap_mib"); + positiveLong(limits.get("max_cpu"), "max_cpu"); + positiveLong(limits.get("analysis_timeout_seconds"), "analysis_timeout_seconds"); + Object decompileTimeout = limits.get("decompile_timeout_seconds"); + long timeout = + decompileTimeout == null ? 60 : positiveLong(decompileTimeout, "decompile_timeout_seconds"); + positiveLong(limits.get("child_watchdog_seconds"), "child_watchdog_seconds"); + positiveLong(limits.get("max_sample_bytes"), "max_sample_bytes"); + positiveLong(limits.get("max_inline_bytes"), "max_inline_bytes"); + Map arguments = asObject(root.get("arguments"), "arguments"); + if (!string(arguments.get("kind"), "argument kind").equals(operation)) { + throw new AdapterException("protocol_violation", "operation and argument kind differ"); + } + return new Request(invocation, operation, arguments, timeout); + } + + private static Map response( + Request request, String status, Object data, AdapterError error) { + Map root = object(); + root.put("protocol_version", PROTOCOL_VERSION); + root.put("invocation_id", request.invocationId); + root.put("operation", request.operation); + Map result = object(); + result.put("status", status); + if (error == null) { + result.put("data", data); + } else { + result.put("code", error.code); + result.put("message", error.message); + result.put("details", error.details); + } + root.put("result", result); + return root; + } + + private static byte[] readBounded(Path path) throws IOException, AdapterException { + try (InputStream input = Files.newInputStream(path); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int remaining = MAX_REQUEST_BYTES + 1; + while (remaining > 0) { + int count = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (count < 0) { + break; + } + output.write(buffer, 0, count); + remaining -= count; + } + if (output.size() > MAX_REQUEST_BYTES) { + throw new AdapterException("protocol_violation", "request exceeds 1 MiB"); + } + return output.toByteArray(); + } + } + + private static String decodeUtf8(byte[] bytes) throws AdapterException { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException error) { + throw new AdapterException("protocol_violation", "request is not valid UTF-8"); + } + } + + private static void writeResponse(Path temporary, Path response, Object value) + throws IOException, ResponseTooLargeException { + Files.deleteIfExists(temporary); + try (OutputStream raw = Files.newOutputStream( + temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + BoundedOutputStream bounded = new BoundedOutputStream(raw, MAX_RESPONSE_BYTES)) { + JsonWriter.write(value, bounded); + bounded.flush(); + } catch (ResponseTooLargeException error) { + Files.deleteIfExists(temporary); + throw error; + } + try { + Files.move(temporary, response, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException error) { + Files.deleteIfExists(temporary); + throw new IOException("response filesystem does not support atomic rename", error); + } + } + + private void expectProgram() throws AdapterException { + if (currentProgram == null) { + throw new AdapterException("analysis_failed", "operation requires an imported Program"); + } + } + + private static void requireKind(Map arguments, String expected) + throws AdapterException { + if (!string(arguments.get("kind"), "argument kind").equals(expected)) { + throw new AdapterException("protocol_violation", "unexpected argument kind"); + } + } + + private static Long parseLimit(Object value) throws AdapterException { + if (value instanceof String string && string.equals("all")) { + return null; + } + Map tagged = asObject(value, "limit"); + expectKeys(tagged, Set.of("bounded"), "bounded limit"); + return positiveLong(tagged.get("bounded"), "limit"); + } + + private static void nullableAbsolutePath(Object value, String name) throws AdapterException { + if (value == null) { + return; + } + String path = string(value, name); + try { + if (!Path.of(path).isAbsolute()) { + throw new AdapterException("protocol_violation", name + " must be absolute"); + } + } catch (java.nio.file.InvalidPathException error) { + throw new AdapterException("protocol_violation", name + " is not a valid worker path"); + } + } + + private static long positiveLong(Object value, String name) throws AdapterException { + long result = nonNegativeLong(value, name); + if (result == 0) { + throw new AdapterException("protocol_violation", name + " must be positive"); + } + return result; + } + + private static long nonNegativeLong(Object value, String name) throws AdapterException { + if (!(value instanceof Long number) || number < 0) { + throw new AdapterException("protocol_violation", name + " must be a non-negative integer"); + } + return number; + } + + @SuppressWarnings("unchecked") + private static Map asObject(Object value, String name) throws AdapterException { + if (!(value instanceof Map)) { + throw new AdapterException("protocol_violation", name + " must be an object"); + } + return (Map) value; + } + + private static String string(Object value, String name) throws AdapterException { + if (!(value instanceof String text)) { + throw new AdapterException("protocol_violation", name + " must be a string"); + } + return text; + } + + private static void expectKeys(Map object, Set expected, String name) + throws AdapterException { + if (!object.keySet().equals(expected)) { + throw new AdapterException("protocol_violation", name + " has missing or unknown fields"); + } + } + + private static long saturatingAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + + private static BigInteger unsigned(long value) { + return new BigInteger(Long.toUnsignedString(value)); + } + + private static int compareUtf8(String left, String right) { + byte[] a = left.getBytes(StandardCharsets.UTF_8); + byte[] b = right.getBytes(StandardCharsets.UTF_8); + int length = Math.min(a.length, b.length); + for (int index = 0; index < length; index++) { + int compared = Integer.compare(Byte.toUnsignedInt(a[index]), Byte.toUnsignedInt(b[index])); + if (compared != 0) { + return compared; + } + } + return Integer.compare(a.length, b.length); + } + + private static String normalizeLineEndings(String input) { + return input.replace("\r\n", "\n").replace('\r', '\n'); + } + + private static Map object() { + return new LinkedHashMap<>(); + } + + private record Request(String invocationId, String operation, Map arguments, + long decompileTimeoutSeconds) {} + + private record AdapterError(String code, String message, Map details) {} + + private static final class AdapterException extends Exception { + private static final long serialVersionUID = 1L; + + final String code; + final Map details; + + AdapterException(String code, String message) { + this(code, message, object()); + } + + AdapterException(String code, String message, Map details) { + super(message); + this.code = code; + this.details = details; + } + } + + private static final class ResponseTooLargeException extends IOException { + private static final long serialVersionUID = 1L; + + final long observedAtLeast; + + ResponseTooLargeException(long observedAtLeast) { + super("response exceeds fixed bound"); + this.observedAtLeast = observedAtLeast; + } + } + + private static final class BoundedOutputStream extends OutputStream { + private final OutputStream delegate; + private final long limit; + private long written; + + BoundedOutputStream(OutputStream delegate, long limit) { + this.delegate = delegate; + this.limit = limit; + } + + @Override + public void write(int value) throws IOException { + ensure(1); + delegate.write(value); + written++; + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + ensure(length); + delegate.write(bytes, offset, length); + written += length; + } + + private void ensure(int additional) throws ResponseTooLargeException { + if (additional > limit - written) { + throw new ResponseTooLargeException(written + 1); + } + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + private static final class JsonWriter { + private JsonWriter() {} + + static void write(Object value, OutputStream output) throws IOException { + if (value == null) { + bytes(output, "null"); + } else if (value instanceof String string) { + string(output, string); + } else if (value instanceof Boolean || value instanceof Number) { + bytes(output, value.toString()); + } else if (value instanceof Map map) { + output.write('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + output.write(','); + } + first = false; + string(output, entry.getKey().toString()); + output.write(':'); + write(entry.getValue(), output); + } + output.write('}'); + } else if (value instanceof Iterable iterable) { + output.write('['); + boolean first = true; + for (Object item : iterable) { + if (!first) { + output.write(','); + } + first = false; + write(item, output); + } + output.write(']'); + } else { + throw new IOException("unsupported JSON value"); + } + } + + private static void string(OutputStream output, String value) throws IOException { + output.write('"'); + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + switch (character) { + case '"' -> bytes(output, "\\\""); + case '\\' -> bytes(output, "\\\\"); + case '\b' -> bytes(output, "\\b"); + case '\f' -> bytes(output, "\\f"); + case '\n' -> bytes(output, "\\n"); + case '\r' -> bytes(output, "\\r"); + case '\t' -> bytes(output, "\\t"); + default -> { + if (character < 0x20) { + bytes(output, String.format("\\u%04x", (int) character)); + } else { + int codePoint = value.codePointAt(index); + byte[] encoded = + new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); + output.write(encoded); + if (Character.isSupplementaryCodePoint(codePoint)) { + index++; + } + } + } + } + } + output.write('"'); + } + + private static void bytes(OutputStream output, String value) throws IOException { + output.write(value.getBytes(StandardCharsets.UTF_8)); + } + } + + private static final class JsonParser { + private final String input; + private int offset; + + JsonParser(String input) { + this.input = input; + } + + Object parse() throws AdapterException { + Object value = value(); + whitespace(); + if (offset != input.length()) { + fail(); + } + return value; + } + + private Object value() throws AdapterException { + whitespace(); + if (offset >= input.length()) { + return fail(); + } + return switch (input.charAt(offset)) { + case '{' -> objectValue(); + case '[' -> arrayValue(); + case '"' -> stringValue(); + case 't' -> literal("true", Boolean.TRUE); + case 'f' -> literal("false", Boolean.FALSE); + case 'n' -> literal("null", null); + default -> numberValue(); + }; + } + + private Map objectValue() throws AdapterException { + offset++; + Map result = object(); + whitespace(); + if (consume('}')) { + return result; + } + while (true) { + whitespace(); + if (offset >= input.length() || input.charAt(offset) != '"') { + return fail(); + } + String key = stringValue(); + if (result.containsKey(key)) { + throw new AdapterException("protocol_violation", "duplicate JSON object key"); + } + whitespace(); + if (!consume(':')) { + return fail(); + } + result.put(key, value()); + whitespace(); + if (consume('}')) { + return result; + } + if (!consume(',')) { + return fail(); + } + } + } + + private List arrayValue() throws AdapterException { + offset++; + List result = new ArrayList<>(); + whitespace(); + if (consume(']')) { + return result; + } + while (true) { + result.add(value()); + whitespace(); + if (consume(']')) { + return result; + } + if (!consume(',')) { + return fail(); + } + } + } + + private String stringValue() throws AdapterException { + offset++; + StringBuilder result = new StringBuilder(); + while (offset < input.length()) { + char character = input.charAt(offset++); + if (character == '"') { + return validString(result.toString()); + } + if (character == '\\') { + if (offset >= input.length()) { + return fail(); + } + char escaped = input.charAt(offset++); + switch (escaped) { + case '"', '\\', '/' -> result.append(escaped); + case 'b' -> result.append('\b'); + case 'f' -> result.append('\f'); + case 'n' -> result.append('\n'); + case 'r' -> result.append('\r'); + case 't' -> result.append('\t'); + case 'u' -> result.append(unicodeEscape()); + default -> throw new AdapterException("protocol_violation", "invalid JSON escape"); + } + } else if (character < 0x20) { + return fail(); + } else { + result.append(character); + } + } + return fail(); + } + + private String validString(String value) throws AdapterException { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (Character.isHighSurrogate(character)) { + if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) { + throw new AdapterException( + "protocol_violation", "JSON string has an unpaired surrogate"); + } + index++; + } else if (Character.isLowSurrogate(character)) { + throw new AdapterException("protocol_violation", "JSON string has an unpaired surrogate"); + } + } + return value; + } + + private char unicodeEscape() throws AdapterException { + if (offset + 4 > input.length()) { + return fail(); + } + try { + char value = (char) Integer.parseInt(input.substring(offset, offset + 4), 16); + offset += 4; + return value; + } catch (NumberFormatException error) { + return fail(); + } + } + + private Object numberValue() throws AdapterException { + int start = offset; + if (offset < input.length() && input.charAt(offset) == '-') { + offset++; + } + if (offset >= input.length() || !Character.isDigit(input.charAt(offset))) { + return fail(); + } + if (input.charAt(offset) == '0') { + offset++; + } else { + while (offset < input.length() && Character.isDigit(input.charAt(offset))) { + offset++; + } + } + if (offset < input.length() && ".eE+".indexOf(input.charAt(offset)) >= 0) { + throw new AdapterException("protocol_violation", "floating-point JSON is not accepted"); + } + try { + return Long.valueOf(input.substring(start, offset)); + } catch (NumberFormatException error) { + throw new AdapterException("protocol_violation", "JSON integer is out of range"); + } + } + + private Object literal(String text, Object value) throws AdapterException { + if (!input.startsWith(text, offset)) { + return fail(); + } + offset += text.length(); + return value; + } + + private boolean consume(char expected) { + if (offset < input.length() && input.charAt(offset) == expected) { + offset++; + return true; + } + return false; + } + + private void whitespace() { + while (offset < input.length() && " \n\r\t".indexOf(input.charAt(offset)) >= 0) { + offset++; + } + } + + private T fail() throws AdapterException { + throw new AdapterException("protocol_violation", "malformed JSON request"); + } + } +} diff --git a/java/dispatch.txt b/java/dispatch.txt new file mode 100644 index 0000000..784d0f8 --- /dev/null +++ b/java/dispatch.txt @@ -0,0 +1,8 @@ +# Java accepts only operations which require a Ghidra/JVM worker. +doctor +inspect +functions +decompile + +# clean is deliberately absent: cleanup is a trusted Rust Analysis Store +# transaction and is never delegated into the capability-sandboxed worker. diff --git a/tests/adapter_contract.rs b/tests/adapter_contract.rs new file mode 100644 index 0000000..759f588 --- /dev/null +++ b/tests/adapter_contract.rs @@ -0,0 +1,48 @@ +#![allow(clippy::expect_used)] +#![doc = "Java dispatch, protocol-bound, and capability-surface regressions."] + +use std::{collections::BTreeSet, fs, path::PathBuf}; + +use ghidra_cli::operation::OPERATIONS; + +fn root(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +#[test] +fn java_dispatch_matches_only_worker_owned_registry_operations() { + let dispatch = fs::read_to_string(root("java/dispatch.txt")).expect("read Java dispatch"); + let java: BTreeSet<&str> = dispatch + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); + let rust_worker: BTreeSet<&str> = OPERATIONS + .iter() + .map(|descriptor| descriptor.operation.as_str()) + .filter(|operation| *operation != "clean") + .collect(); + assert_eq!(java, rust_worker); + assert!(!java.contains("clean")); +} + +#[test] +fn java_source_has_protocol_bounds_and_no_egress_or_mutation_surface() { + let source = fs::read_to_string(root("java/GhidrAdapter.java")).expect("read adapter"); + assert!(source.contains("MAX_REQUEST_BYTES = 1_048_576")); + assert!(source.contains("MAX_RESPONSE_BYTES = 268_435_456L")); + for forbidden in [ + "ServerSocket", + "HttpServer", + "java.net.", + "setName(", + "startTransaction(", + "runScript(", + "executeScript(", + ] { + assert!( + !source.contains(forbidden), + "forbidden Java surface: {forbidden}" + ); + } +} diff --git a/tests/data/adapter-doctor-request.json b/tests/data/adapter-doctor-request.json new file mode 100644 index 0000000..4b88c94 --- /dev/null +++ b/tests/data/adapter-doctor-request.json @@ -0,0 +1,19 @@ +{ + "protocol_version": 1, + "invocation_id": "fedcba9876543210fedcba9876543210", + "operation": "doctor", + "staged_sample": null, + "analysis_path": null, + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 60, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 120, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + }, + "arguments": { + "kind": "doctor" + } +} diff --git a/tests/data/adapter-inspect-request.json b/tests/data/adapter-inspect-request.json new file mode 100644 index 0000000..c4f6fcf --- /dev/null +++ b/tests/data/adapter-inspect-request.json @@ -0,0 +1,19 @@ +{ + "protocol_version": 1, + "invocation_id": "0123456789abcdef0123456789abcdef", + "operation": "inspect", + "staged_sample": "@SAMPLE@", + "analysis_path": "@ANALYSIS@", + "limits": { + "max_heap_mib": 2048, + "max_cpu": 2, + "analysis_timeout_seconds": 60, + "decompile_timeout_seconds": null, + "child_watchdog_seconds": 180, + "max_sample_bytes": 1073741824, + "max_inline_bytes": 65536 + }, + "arguments": { + "kind": "inspect" + } +} diff --git a/tests/real_ghidra_adapter.sh b/tests/real_ghidra_adapter.sh new file mode 100644 index 0000000..e2752d3 --- /dev/null +++ b/tests/real_ghidra_adapter.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${GHIDR_ANALYZE_HEADLESS:?set by the Nix check or dev shell}" +: "${GHIDR_ADAPTER_PATH:?set by the Nix check or dev shell}" +: "${GHIDR_FIXTURES:?set by the Nix check}" + +test_root="${TMPDIR:?}/ghidr-adapter-e2e" +mkdir -p "$test_root/project" "$test_root/invocation" +doctor_request="$test_root/invocation/doctor-request.json" +doctor_response="$test_root/invocation/doctor-response.json" +request="$test_root/invocation/request.json" +response="$test_root/invocation/response.json" +sample="$GHIDR_FIXTURES/elf-x86_64/known.elf" + +cp tests/data/adapter-doctor-request.json "$doctor_request" +"$GHIDR_ANALYZE_HEADLESS" "$test_root" doctor-probe \ + -noanalysis \ + -scriptPath "$GHIDR_ADAPTER_PATH" \ + -postScript GhidrAdapter.java "$doctor_request" "$doctor_response" \ + -deleteProject +jq -e ' + .operation == "doctor" and + .result.status == "success" and + .result.data.ready == true and + .result.data.ghidra_version == "12.1.2" and + .result.data.java_version == 21 +' "$doctor_response" + +sed \ + -e "s|@SAMPLE@|$sample|g" \ + -e "s|@ANALYSIS@|$test_root/project|g" \ + tests/data/adapter-inspect-request.json > "$request" + +"$GHIDR_ANALYZE_HEADLESS" "$test_root/project" analysis \ + -import "$sample" \ + -analysisTimeoutPerFile 60 \ + -max-cpu 2 \ + -scriptPath "$GHIDR_ADAPTER_PATH" \ + -postScript GhidrAdapter.java "$request" "$response" + +jq -e ' + .protocol_version == 1 and + .invocation_id == "0123456789abcdef0123456789abcdef" and + .operation == "inspect" and + .result.status == "success" and + .result.data.target.processor_language == "x86:LE:64:default" +' "$response" + +test ! -e "$response.tmp" From b9b9bfe05a666b4a4664b50c2696952b2377d704 Mon Sep 17 00:00:00 2001 From: hermes Date: Tue, 28 Jul 2026 19:51:32 +0000 Subject: [PATCH 2/2] feat: implement worker lifecycle and sandbox --- Cargo.lock | 267 +++++++++++++++- Cargo.toml | 9 + src/domain/identifier.rs | 19 ++ src/lib.rs | 2 + src/process/capture.rs | 219 +++++++++++++ src/process/diagnostics.rs | 369 +++++++++++++++++++++ src/process/lifecycle.rs | 616 ++++++++++++++++++++++++++++++++++++ src/process/mod.rs | 10 + src/protocol.rs | 230 +++++++++++++- src/sandbox/mod.rs | 303 ++++++++++++++++++ tests/bubblewrap_probe.rs | 114 +++++++ tests/support/fake_child.rs | 118 +++++++ tests/worker_lifecycle.rs | 187 +++++++++++ 13 files changed, 2456 insertions(+), 7 deletions(-) create mode 100644 src/process/capture.rs create mode 100644 src/process/diagnostics.rs create mode 100644 src/process/lifecycle.rs create mode 100644 src/process/mod.rs create mode 100644 src/sandbox/mod.rs create mode 100644 tests/bubblewrap_probe.rs create mode 100644 tests/support/fake_child.rs create mode 100644 tests/worker_lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index daa43d2..e5de3cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +49,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -73,6 +73,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.13.0" @@ -84,6 +99,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + [[package]] name = "clap" version = "4.6.4" @@ -130,18 +151,85 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + [[package]] name = "ghidra-cli" version = "0.1.0" @@ -149,9 +237,14 @@ dependencies = [ "assert_cmd", "base64", "clap", + "getrandom", + "hex", + "rustix", "schemars", "serde", "serde_json", + "sha2", + "tempfile", "thiserror", ] @@ -161,6 +254,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -179,12 +278,24 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -236,6 +347,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "ref-cast" version = "1.0.26" @@ -262,6 +379,19 @@ version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + [[package]] name = "schemars" version = "1.0.4" @@ -341,6 +471,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "strsim" version = "0.11.1" @@ -369,6 +510,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + [[package]] name = "termtree" version = "0.5.1" @@ -395,6 +549,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -407,6 +567,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -416,21 +582,116 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.14.3+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link 0.1.3", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index efabe06..66e9715 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,16 +15,25 @@ 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" +hex = "0.4.3" +rustix = { version = "=1.0.8", features = ["process"] } schemars = { version = "=1.0.4", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +sha2 = "0.10.9" thiserror = "2.0.18" [dev-dependencies] assert_cmd = "2.1.2" +tempfile = "=3.21.0" [lints.rust] unsafe_code = "forbid" 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/lib.rs b/src/lib.rs index 858c23b..8ff5cab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,9 @@ pub mod domain; pub mod error; pub mod operation; pub mod output; +pub mod process; pub mod protocol; +pub mod sandbox; pub mod schema; pub use error::{AppError, ErrorCode, ExitStatus}; 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..0e59a4f --- /dev/null +++ b/src/process/lifecycle.rs @@ -0,0 +1,616 @@ +//! 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(debug_assertions)] + #[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, +) -> 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) + .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, + 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, +) -> 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..f608f3f --- /dev/null +++ b/src/process/mod.rs @@ -0,0 +1,10 @@ +//! Synchronous worker lifecycle, bounded diagnostics, and publication seams. + +pub mod capture; +pub mod diagnostics; +mod lifecycle; + +pub use lifecycle::{ + Cancellation, CompletedInvocation, Invocation, InvocationFailure, InvocationFailureKind, + NativePhaseTimeouts, NeverCancel, TERMINATION_GRACE, WatchdogError, run, +}; diff --git a/src/protocol.rs b/src/protocol.rs index 1c5dfbc..791835f 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,7 +14,10 @@ use thiserror::Error; use crate::{ domain::{FunctionSelector, InvocationId, Limits, PageRequest}, - operation::{ADAPTER_PROTOCOL_VERSION, Operation}, + operation::{ + ADAPTER_PROTOCOL_VERSION, CleanupData, DecompilationData, DoctorData, FunctionsData, + InspectionData, Operation, + }, }; /// Maximum exact serialized `request.json` size (1 MiB). @@ -20,6 +25,13 @@ 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 +176,129 @@ 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(DoctorData), + /// Program inspection result. + Inspect(InspectionData), + /// Stable Function page. + Functions(FunctionsData), + /// Complete decompilation. + Decompile(DecompilationData), + /// Cleanup parity result; normal cleanup remains in trusted Rust. + Clean(CleanupData), +} + +/// 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!(DoctorData, Doctor), + Operation::Inspect => exact!(InspectionData, Inspect), + Operation::Functions => exact!(FunctionsData, Functions), + Operation::Decompile => exact!(DecompilationData, Decompile), + Operation::Clean => exact!(CleanupData, Clean), + } +} + +/// 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 +373,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 +410,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 +487,41 @@ 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, + "components": {}, + "unexpected": true + }); + assert!(matches!( + validate_operation_data(Operation::Doctor, data), + Err(ProtocolCodecError::OperationSchemaMismatch) + )); + } + + #[test] + fn invocation_directory_must_be_absolute() { + assert!(matches!( + InvocationFiles::new(PathBuf::from("relative")), + Err(ProtocolCodecError::InvalidWorkerPath) + )); + } } diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs new file mode 100644 index 0000000..322fcba --- /dev/null +++ b/src/sandbox/mod.rs @@ -0,0 +1,303 @@ +//! Explicit worker sandbox policy and production Bubblewrap command construction. + +use std::{ + collections::BTreeMap, + 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, + /// 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, + 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, + provenance: SandboxProvenance { + backend: SandboxBackend::Off, + verification: SandboxVerification::Disabled, + }, + warnings: vec![policy_warning( + "sandbox_disabled", + "worker sandbox isolation is explicitly disabled", + )], + }), + } + } +} + +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", + "--proc", + "/proc", + "--dev", + "/dev", + ]); + // 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, + provenance: SandboxProvenance { + backend: SandboxBackend::Bubblewrap, + verification: SandboxVerification::Verified, + }, + warnings: Vec::new(), + }) +} + +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/tests/bubblewrap_probe.rs b/tests/bubblewrap_probe.rs new file mode 100644 index 0000000..3eba4aa --- /dev/null +++ b/tests/bubblewrap_probe.rs @@ -0,0 +1,114 @@ +#![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.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/support/fake_child.rs b/tests/support/fake_child.rs new file mode 100644 index 0000000..5633146 --- /dev/null +++ b/tests/support/fake_child.rs @@ -0,0 +1,118 @@ +#![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 == "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 request = PathBuf::from(arguments.next().ok_or("missing request path")?); + let response = PathBuf::from(arguments.next().ok_or("missing response path")?); + match behavior.to_str().ok_or("behavior is not UTF-8")? { + "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 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": { + "status": "success", + "data": { + "ready": true, + "components": {} + } + } + }); + 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(()) +} 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() + ); +}