feat: integrate ghidra-cli v0.1

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

48
tests/adapter_contract.rs Normal file
View file

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

120
tests/bubblewrap_probe.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
other sample

View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
: "${GHIDR_ANALYZE_HEADLESS:?set by the Nix check or dev shell}"
: "${GHIDR_ADAPTER_PATH:?set by the Nix check or dev shell}"
: "${GHIDR_FIXTURES:?set by the Nix check}"
test_root="${TMPDIR:?}/ghidr-adapter-e2e"
mkdir -p "$test_root/project" "$test_root/invocation"
doctor_request="$test_root/invocation/doctor-request.json"
doctor_response="$test_root/invocation/doctor-response.json"
request="$test_root/invocation/request.json"
response="$test_root/invocation/response.json"
sample="$GHIDR_FIXTURES/elf-x86_64/known.elf"
cp tests/data/adapter-doctor-request.json "$doctor_request"
"$GHIDR_ANALYZE_HEADLESS" "$test_root" doctor-probe \
-noanalysis \
-scriptPath "$GHIDR_ADAPTER_PATH" \
-postScript GhidrAdapter.java "$doctor_request" "$doctor_response" \
-deleteProject
jq -e '
.operation == "doctor" and
.result.status == "success" and
.result.data.ready == true and
.result.data.ghidra_version == "12.1.2" and
.result.data.java_version == 21
' "$doctor_response"
sed \
-e "s|@SAMPLE@|$sample|g" \
-e "s|@ANALYSIS@|$test_root/project|g" \
tests/data/adapter-inspect-request.json > "$request"
"$GHIDR_ANALYZE_HEADLESS" "$test_root/project" analysis \
-import "$sample" \
-analysisTimeoutPerFile 60 \
-max-cpu 2 \
-scriptPath "$GHIDR_ADAPTER_PATH" \
-postScript GhidrAdapter.java "$request" "$response"
jq -e '
.protocol_version == 1 and
.invocation_id == "0123456789abcdef0123456789abcdef" and
.operation == "inspect" and
.result.status == "success" and
.result.data.context.target.processor_language == "x86:LE:64:default" and
(.result.data.context.analyzer_options | length) > 0
' "$response"
test ! -e "$response.tmp"

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

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

187
tests/worker_lifecycle.rs Normal file
View file

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