feat: integrate ghidra-cli v0.1
This commit is contained in:
commit
ffab80a1f2
52 changed files with 6689 additions and 126 deletions
48
tests/adapter_contract.rs
Normal file
48
tests/adapter_contract.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#![allow(clippy::expect_used)]
|
||||
#![doc = "Java dispatch, protocol-bound, and capability-surface regressions."]
|
||||
|
||||
use std::{collections::BTreeSet, fs, path::PathBuf};
|
||||
|
||||
use ghidra_cli::operation::OPERATIONS;
|
||||
|
||||
fn root(relative: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_dispatch_matches_only_worker_owned_registry_operations() {
|
||||
let dispatch = fs::read_to_string(root("java/dispatch.txt")).expect("read Java dispatch");
|
||||
let java: BTreeSet<&str> = dispatch
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.collect();
|
||||
let rust_worker: BTreeSet<&str> = OPERATIONS
|
||||
.iter()
|
||||
.map(|descriptor| descriptor.operation.as_str())
|
||||
.filter(|operation| *operation != "clean")
|
||||
.collect();
|
||||
assert_eq!(java, rust_worker);
|
||||
assert!(!java.contains("clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_source_has_protocol_bounds_and_no_egress_or_mutation_surface() {
|
||||
let source = fs::read_to_string(root("java/GhidrAdapter.java")).expect("read adapter");
|
||||
assert!(source.contains("MAX_REQUEST_BYTES = 1_048_576"));
|
||||
assert!(source.contains("MAX_RESPONSE_BYTES = 268_435_456L"));
|
||||
for forbidden in [
|
||||
"ServerSocket",
|
||||
"HttpServer",
|
||||
"java.net.",
|
||||
"setName(",
|
||||
"startTransaction(",
|
||||
"runScript(",
|
||||
"executeScript(",
|
||||
] {
|
||||
assert!(
|
||||
!source.contains(forbidden),
|
||||
"forbidden Java surface: {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
120
tests/bubblewrap_probe.rs
Normal file
120
tests/bubblewrap_probe.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
#![allow(clippy::expect_used)]
|
||||
#![doc = "Host-permitted production Bubblewrap network-isolation probe."]
|
||||
|
||||
use std::{
|
||||
env,
|
||||
ffi::OsString,
|
||||
net::TcpListener,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use ghidra_cli::{
|
||||
cli::SandboxMode,
|
||||
sandbox::{BubblewrapConfig, WorkerCommand},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn production_bubblewrap_command_cannot_reach_host_listener_when_supported() {
|
||||
let Some(bubblewrap) = bubblewrap_path() else {
|
||||
return;
|
||||
};
|
||||
let fake_child = PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child"));
|
||||
let Ok(listener) = TcpListener::bind("127.0.0.1:0") else {
|
||||
// Some CI sandboxes prohibit even loopback listeners.
|
||||
return;
|
||||
};
|
||||
let address = listener.local_addr().expect("listener address").to_string();
|
||||
|
||||
let direct = Command::new(&fake_child)
|
||||
.args(["network-probe", &address])
|
||||
.status()
|
||||
.expect("direct probe process");
|
||||
assert!(
|
||||
direct.success(),
|
||||
"control probe must reach the host listener"
|
||||
);
|
||||
|
||||
let private = tempfile::tempdir().expect("sandbox private directory");
|
||||
let home = private.path().join("home");
|
||||
let temporary = private.path().join("tmp");
|
||||
std::fs::create_dir(&home).expect("private home");
|
||||
std::fs::create_dir(&temporary).expect("private tmp");
|
||||
|
||||
let benign = bubblewrap_command(
|
||||
&bubblewrap,
|
||||
&fake_child,
|
||||
vec![OsString::from("exit-success")],
|
||||
&home,
|
||||
&temporary,
|
||||
);
|
||||
let benign_status = Command::new(&benign.program)
|
||||
.args(&benign.arguments)
|
||||
.status()
|
||||
.expect("Bubblewrap capability probe");
|
||||
if !benign_status.success() {
|
||||
// User namespaces are legitimately unavailable on some test hosts.
|
||||
return;
|
||||
}
|
||||
|
||||
let isolated = bubblewrap_command(
|
||||
&bubblewrap,
|
||||
&fake_child,
|
||||
vec![OsString::from("network-probe"), OsString::from(address)],
|
||||
&home,
|
||||
&temporary,
|
||||
);
|
||||
let isolated_status = Command::new(&isolated.program)
|
||||
.args(&isolated.arguments)
|
||||
.status()
|
||||
.expect("isolated probe");
|
||||
assert!(
|
||||
!isolated_status.success(),
|
||||
"worker unexpectedly reached a host listener across --unshare-all"
|
||||
);
|
||||
}
|
||||
|
||||
fn bubblewrap_command(
|
||||
bubblewrap: &Path,
|
||||
fake_child: &Path,
|
||||
worker_arguments: Vec<OsString>,
|
||||
home: &Path,
|
||||
temporary: &Path,
|
||||
) -> WorkerCommand {
|
||||
WorkerCommand::for_policy(
|
||||
SandboxMode::Bubblewrap,
|
||||
fake_child.to_path_buf(),
|
||||
worker_arguments.clone(),
|
||||
Some(BubblewrapConfig {
|
||||
bubblewrap: bubblewrap.to_path_buf(),
|
||||
worker_program: fake_child.to_path_buf(),
|
||||
worker_arguments,
|
||||
readonly_paths: vec![
|
||||
PathBuf::from("/nix/store"),
|
||||
fake_child
|
||||
.parent()
|
||||
.expect("fake child parent")
|
||||
.to_path_buf(),
|
||||
],
|
||||
staged_sample: None,
|
||||
writable_paths: Vec::new(),
|
||||
private_home: home.to_path_buf(),
|
||||
private_tmp: temporary.to_path_buf(),
|
||||
}),
|
||||
)
|
||||
.expect("production Bubblewrap command")
|
||||
}
|
||||
|
||||
fn bubblewrap_path() -> Option<PathBuf> {
|
||||
env::var_os("GHIDR_TEST_BWRAP")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| find_in_path("bwrap"))
|
||||
}
|
||||
|
||||
fn find_in_path(executable: &str) -> Option<PathBuf> {
|
||||
env::var_os("PATH").and_then(|path| {
|
||||
env::split_paths(&path)
|
||||
.map(|directory| directory.join(executable))
|
||||
.find(|candidate| candidate.is_file())
|
||||
})
|
||||
}
|
||||
281
tests/cli.rs
281
tests/cli.rs
|
|
@ -1,20 +1,237 @@
|
|||
#![allow(clippy::expect_used)]
|
||||
#![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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
tests/data/adapter-doctor-request.json
Normal file
19
tests/data/adapter-doctor-request.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"protocol_version": 1,
|
||||
"invocation_id": "fedcba9876543210fedcba9876543210",
|
||||
"operation": "doctor",
|
||||
"staged_sample": null,
|
||||
"analysis_path": null,
|
||||
"limits": {
|
||||
"max_heap_mib": 2048,
|
||||
"max_cpu": 2,
|
||||
"analysis_timeout_seconds": 60,
|
||||
"decompile_timeout_seconds": null,
|
||||
"child_watchdog_seconds": 120,
|
||||
"max_sample_bytes": 1073741824,
|
||||
"max_inline_bytes": 65536
|
||||
},
|
||||
"arguments": {
|
||||
"kind": "doctor"
|
||||
}
|
||||
}
|
||||
19
tests/data/adapter-inspect-request.json
Normal file
19
tests/data/adapter-inspect-request.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"protocol_version": 1,
|
||||
"invocation_id": "0123456789abcdef0123456789abcdef",
|
||||
"operation": "inspect",
|
||||
"staged_sample": "@SAMPLE@",
|
||||
"analysis_path": "@ANALYSIS@",
|
||||
"limits": {
|
||||
"max_heap_mib": 2048,
|
||||
"max_cpu": 2,
|
||||
"analysis_timeout_seconds": 60,
|
||||
"decompile_timeout_seconds": null,
|
||||
"child_watchdog_seconds": 180,
|
||||
"max_sample_bytes": 1073741824,
|
||||
"max_inline_bytes": 65536
|
||||
},
|
||||
"arguments": {
|
||||
"kind": "inspect"
|
||||
}
|
||||
}
|
||||
1
tests/fixtures/fake-worker-error.sample
vendored
Normal file
1
tests/fixtures/fake-worker-error.sample
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
other sample
|
||||
51
tests/real_ghidra_adapter.sh
Normal file
51
tests/real_ghidra_adapter.sh
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${GHIDR_ANALYZE_HEADLESS:?set by the Nix check or dev shell}"
|
||||
: "${GHIDR_ADAPTER_PATH:?set by the Nix check or dev shell}"
|
||||
: "${GHIDR_FIXTURES:?set by the Nix check}"
|
||||
|
||||
test_root="${TMPDIR:?}/ghidr-adapter-e2e"
|
||||
mkdir -p "$test_root/project" "$test_root/invocation"
|
||||
doctor_request="$test_root/invocation/doctor-request.json"
|
||||
doctor_response="$test_root/invocation/doctor-response.json"
|
||||
request="$test_root/invocation/request.json"
|
||||
response="$test_root/invocation/response.json"
|
||||
sample="$GHIDR_FIXTURES/elf-x86_64/known.elf"
|
||||
|
||||
cp tests/data/adapter-doctor-request.json "$doctor_request"
|
||||
"$GHIDR_ANALYZE_HEADLESS" "$test_root" doctor-probe \
|
||||
-noanalysis \
|
||||
-scriptPath "$GHIDR_ADAPTER_PATH" \
|
||||
-postScript GhidrAdapter.java "$doctor_request" "$doctor_response" \
|
||||
-deleteProject
|
||||
jq -e '
|
||||
.operation == "doctor" and
|
||||
.result.status == "success" and
|
||||
.result.data.ready == true and
|
||||
.result.data.ghidra_version == "12.1.2" and
|
||||
.result.data.java_version == 21
|
||||
' "$doctor_response"
|
||||
|
||||
sed \
|
||||
-e "s|@SAMPLE@|$sample|g" \
|
||||
-e "s|@ANALYSIS@|$test_root/project|g" \
|
||||
tests/data/adapter-inspect-request.json > "$request"
|
||||
|
||||
"$GHIDR_ANALYZE_HEADLESS" "$test_root/project" analysis \
|
||||
-import "$sample" \
|
||||
-analysisTimeoutPerFile 60 \
|
||||
-max-cpu 2 \
|
||||
-scriptPath "$GHIDR_ADAPTER_PATH" \
|
||||
-postScript GhidrAdapter.java "$request" "$response"
|
||||
|
||||
jq -e '
|
||||
.protocol_version == 1 and
|
||||
.invocation_id == "0123456789abcdef0123456789abcdef" and
|
||||
.operation == "inspect" and
|
||||
.result.status == "success" and
|
||||
.result.data.context.target.processor_language == "x86:LE:64:default" and
|
||||
(.result.data.context.analyzer_options | length) > 0
|
||||
' "$response"
|
||||
|
||||
test ! -e "$response.tmp"
|
||||
271
tests/support/fake_child.rs
Normal file
271
tests/support/fake_child.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
#![forbid(unsafe_code)]
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use std::{
|
||||
env,
|
||||
fs::{self, OpenOptions},
|
||||
io::{self, Read as _, Write as _},
|
||||
net::TcpStream,
|
||||
os::unix::fs::OpenOptionsExt as _,
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match execute() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
let _ignored = writeln!(io::stderr().lock(), "fake child: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
let behavior = arguments.next().ok_or("missing behavior")?;
|
||||
if behavior == "-version" {
|
||||
writeln!(io::stderr().lock(), "openjdk version \"21\"")?;
|
||||
return Ok(());
|
||||
}
|
||||
if behavior == "exit-success" {
|
||||
return Ok(());
|
||||
}
|
||||
if behavior == "network-probe" {
|
||||
let address = arguments.next().ok_or("missing probe address")?;
|
||||
TcpStream::connect(address.to_str().ok_or("probe address is not UTF-8")?)?;
|
||||
return Ok(());
|
||||
}
|
||||
let behavior_text = behavior.to_str().ok_or("behavior is not UTF-8")?;
|
||||
if !matches!(
|
||||
behavior_text,
|
||||
"success" | "mismatch" | "temporary-only" | "no-response" | "sleep" | "flood"
|
||||
) {
|
||||
return analyze_headless(PathBuf::from(behavior), arguments.collect());
|
||||
}
|
||||
let request = PathBuf::from(arguments.next().ok_or("missing request path")?);
|
||||
let response = PathBuf::from(arguments.next().ok_or("missing response path")?);
|
||||
match behavior_text {
|
||||
"success" => write_response(&request, &response, false, true),
|
||||
"mismatch" => write_response(&request, &response, true, true),
|
||||
"temporary-only" => write_response(&request, &response, false, false),
|
||||
"no-response" => Ok(()),
|
||||
"sleep" => {
|
||||
let milliseconds = arguments
|
||||
.next()
|
||||
.ok_or("missing sleep milliseconds")?
|
||||
.to_str()
|
||||
.ok_or("sleep value is not UTF-8")?
|
||||
.parse::<u64>()?;
|
||||
thread::sleep(Duration::from_millis(milliseconds));
|
||||
write_response(&request, &response, false, true)
|
||||
}
|
||||
"flood" => {
|
||||
let count = arguments
|
||||
.next()
|
||||
.ok_or("missing flood byte count")?
|
||||
.to_str()
|
||||
.ok_or("flood value is not UTF-8")?
|
||||
.parse::<usize>()?;
|
||||
write_repeated(io::stdout().lock(), b'o', count)?;
|
||||
write_repeated(io::stderr().lock(), b'e', count)?;
|
||||
write_response(&request, &response, false, true)
|
||||
}
|
||||
_ => Err("unknown behavior".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_headless(
|
||||
project_directory: PathBuf,
|
||||
arguments: Vec<std::ffi::OsString>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if arguments.len() < 3 {
|
||||
return Err("fake analyzeHeadless received too few arguments".into());
|
||||
}
|
||||
let project_name = arguments[0].to_str().ok_or("project name is not UTF-8")?;
|
||||
if arguments.iter().any(|argument| argument == "-import") {
|
||||
fs::create_dir_all(project_directory.join(format!("{project_name}.rep")))?;
|
||||
fs::write(
|
||||
project_directory.join(format!("{project_name}.gpr")),
|
||||
b"fake immutable Ghidra project",
|
||||
)?;
|
||||
fs::write(
|
||||
project_directory
|
||||
.join(format!("{project_name}.rep"))
|
||||
.join("program"),
|
||||
b"fake analyzed program",
|
||||
)?;
|
||||
}
|
||||
let request = PathBuf::from(&arguments[arguments.len() - 2]);
|
||||
let response = PathBuf::from(&arguments[arguments.len() - 1]);
|
||||
let request_value: serde_json::Value = serde_json::from_slice(&fs::read(&request)?)?;
|
||||
if request_value["staged_sample"]
|
||||
.as_str()
|
||||
.and_then(|path| fs::read(path).ok())
|
||||
.is_some_and(|bytes| bytes.starts_with(b"interruptible sample"))
|
||||
{
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
}
|
||||
write_response(&request, &response, false, true)
|
||||
}
|
||||
|
||||
fn write_repeated(mut writer: impl io::Write, byte: u8, mut remaining: usize) -> io::Result<()> {
|
||||
let buffer = vec![byte; 64 * 1024];
|
||||
while remaining > 0 {
|
||||
let count = remaining.min(buffer.len());
|
||||
writer.write_all(&buffer[..count])?;
|
||||
remaining -= count;
|
||||
}
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
fn write_response(
|
||||
request_path: &Path,
|
||||
response_path: &Path,
|
||||
mismatch: bool,
|
||||
publish: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut request_bytes = Vec::new();
|
||||
fs::File::open(request_path)?.read_to_end(&mut request_bytes)?;
|
||||
let request: serde_json::Value = serde_json::from_slice(&request_bytes)?;
|
||||
let invocation_id = if mismatch {
|
||||
serde_json::Value::String("ffffffffffffffffffffffffffffffff".to_owned())
|
||||
} else {
|
||||
request["invocation_id"].clone()
|
||||
};
|
||||
let response = serde_json::json!({
|
||||
"protocol_version": request["protocol_version"],
|
||||
"invocation_id": invocation_id,
|
||||
"operation": request["operation"],
|
||||
"result": if fake_error(&request) {
|
||||
serde_json::json!({
|
||||
"status": "error",
|
||||
"code": "analysis_failed",
|
||||
"message": "controllable fake worker error",
|
||||
"details": {}
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"status": "success",
|
||||
"data": fake_data(&request)
|
||||
})
|
||||
}
|
||||
});
|
||||
let bytes = serde_json::to_vec(&response)?;
|
||||
let temporary = response_path.with_file_name("response.json.tmp");
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
if publish {
|
||||
fs::rename(temporary, response_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fake_error(request: &serde_json::Value) -> bool {
|
||||
request["staged_sample"]
|
||||
.as_str()
|
||||
.and_then(|path| fs::read(path).ok())
|
||||
.is_some_and(|bytes| bytes.starts_with(b"other sample"))
|
||||
}
|
||||
|
||||
fn fake_data(request: &serde_json::Value) -> serde_json::Value {
|
||||
match request["operation"].as_str() {
|
||||
Some("doctor") => serde_json::json!({
|
||||
"ready": true,
|
||||
"ghidra_version": "12.1.2",
|
||||
"java_version": 21,
|
||||
"adapter_protocol_version": 1
|
||||
}),
|
||||
Some("inspect") => query_data(serde_json::json!({
|
||||
"image_base": address("0x0000000000400000"),
|
||||
"minimum_address": address("0x0000000000400000"),
|
||||
"maximum_address": address("0x0000000000400fff"),
|
||||
"function_count": 1
|
||||
})),
|
||||
Some("functions") => {
|
||||
let offset = request["arguments"]["page"]["offset"].as_u64().unwrap_or(0);
|
||||
let items = if offset == 0 {
|
||||
vec![serde_json::json!({
|
||||
"name": "main",
|
||||
"qualified_name": "main",
|
||||
"entry": address("0x0000000000401000"),
|
||||
"body_address_count": 32,
|
||||
"location": "memory",
|
||||
"is_external": false,
|
||||
"is_thunk": false,
|
||||
"thunk_target_entry": null,
|
||||
"decompilable": true
|
||||
})]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let limit = match &request["arguments"]["page"]["limit"] {
|
||||
serde_json::Value::String(value) if value == "all" => serde_json::Value::Null,
|
||||
value => value["bounded"].clone(),
|
||||
};
|
||||
query_data(serde_json::json!({
|
||||
"page": {
|
||||
"order": "location_then_entry_ascending",
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"returned": items.len(),
|
||||
"total": 1,
|
||||
"has_more": false
|
||||
},
|
||||
"items": items
|
||||
}))
|
||||
}
|
||||
Some("decompile") => {
|
||||
let requested = request["arguments"]["selector"]["value"]
|
||||
.as_str()
|
||||
.unwrap_or("main");
|
||||
let text = if requested == "large" {
|
||||
"x".repeat(100_000)
|
||||
} else {
|
||||
"int main(void) {\n return 0;\n}\n".to_owned()
|
||||
};
|
||||
query_data(serde_json::json!({
|
||||
"requested_selector": request["arguments"]["selector"],
|
||||
"function": {
|
||||
"name": requested,
|
||||
"qualified_name": requested,
|
||||
"entry": address("0x0000000000401000")
|
||||
},
|
||||
"decompilation": {
|
||||
"syntax": "ghidra_c",
|
||||
"text": text
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_data(query: serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"context": {
|
||||
"ghidra_version": "12.1.2",
|
||||
"java_version": 21,
|
||||
"target": {
|
||||
"loader": "ElfLoader",
|
||||
"format": "ELF",
|
||||
"processor_language": "x86:LE:64:default",
|
||||
"compiler_specification": "gcc"
|
||||
},
|
||||
"loader_options": [],
|
||||
"analyzer_options": []
|
||||
},
|
||||
"query": query
|
||||
})
|
||||
}
|
||||
|
||||
fn address(offset: &str) -> serde_json::Value {
|
||||
serde_json::json!({"space": "ram", "offset": offset})
|
||||
}
|
||||
187
tests/worker_lifecycle.rs
Normal file
187
tests/worker_lifecycle.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
#![allow(clippy::expect_used)]
|
||||
#![doc = "Controllable fake-child coverage for synchronous worker execution."]
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt as _,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ghidra_cli::{
|
||||
cli::SandboxMode,
|
||||
domain::{InvocationId, Limits, PositiveU64},
|
||||
operation::{ADAPTER_PROTOCOL_VERSION, Operation},
|
||||
process::{
|
||||
Cancellation, Invocation, InvocationFailureKind, NativePhaseTimeouts, NeverCancel, run,
|
||||
},
|
||||
protocol::{AdapterRequest, InvocationFiles, RequestArguments},
|
||||
sandbox::WorkerCommand,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn positive(value: u64) -> PositiveU64 {
|
||||
PositiveU64::try_from(value).expect("positive")
|
||||
}
|
||||
|
||||
fn request() -> AdapterRequest {
|
||||
AdapterRequest {
|
||||
protocol_version: ADAPTER_PROTOCOL_VERSION,
|
||||
invocation_id: InvocationId::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("identifier"),
|
||||
operation: Operation::Doctor,
|
||||
staged_sample: None,
|
||||
analysis_path: None,
|
||||
limits: Limits {
|
||||
max_heap_mib: positive(2048),
|
||||
max_cpu: positive(2),
|
||||
analysis_timeout_seconds: positive(600),
|
||||
decompile_timeout_seconds: None,
|
||||
child_watchdog_seconds: positive(120),
|
||||
max_sample_bytes: positive(1_073_741_824),
|
||||
max_inline_bytes: positive(65_536),
|
||||
},
|
||||
arguments: RequestArguments::Doctor,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(behavior: &str, extra: &[&str]) -> (TempDir, InvocationFiles, WorkerCommand) {
|
||||
let root = tempfile::tempdir().expect("temporary directory");
|
||||
let invocation_directory = root.path().join("invocation");
|
||||
fs::create_dir(&invocation_directory).expect("invocation directory");
|
||||
let files = InvocationFiles::new(invocation_directory).expect("protocol paths");
|
||||
let mut arguments = vec![
|
||||
OsString::from(behavior),
|
||||
files.request_path().into_os_string(),
|
||||
files.response_path().into_os_string(),
|
||||
];
|
||||
arguments.extend(extra.iter().map(OsString::from));
|
||||
let command = WorkerCommand::for_policy(
|
||||
SandboxMode::Off,
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_ghidr-fake-child")),
|
||||
arguments,
|
||||
None,
|
||||
)
|
||||
.expect("worker command");
|
||||
(root, files, command)
|
||||
}
|
||||
|
||||
fn invocation<'a>(
|
||||
request: &'a AdapterRequest,
|
||||
files: &'a InvocationFiles,
|
||||
command: &'a WorkerCommand,
|
||||
) -> Invocation<'a> {
|
||||
Invocation::new(
|
||||
request,
|
||||
files,
|
||||
command,
|
||||
NativePhaseTimeouts {
|
||||
analysis_seconds: None,
|
||||
decompile_seconds: None,
|
||||
},
|
||||
)
|
||||
.expect("derived Invocation")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_attempt_writes_private_request_and_accepts_only_strict_final_response() {
|
||||
let (_root, files, command) = fixture("success", &[]);
|
||||
let request = request();
|
||||
let completed =
|
||||
run(invocation(&request, &files, &command), &NeverCancel).expect("completed invocation");
|
||||
assert_eq!(completed.response.operation, Operation::Doctor);
|
||||
assert_eq!(
|
||||
fs::metadata(files.request_path())
|
||||
.expect("request metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_response_is_never_accepted() {
|
||||
let (_root, files, command) = fixture("temporary-only", &[]);
|
||||
let request = request();
|
||||
let failure = run(invocation(&request, &files, &command), &NeverCancel)
|
||||
.expect_err("temporary response must fail");
|
||||
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_echo_is_a_protocol_violation() {
|
||||
let (_root, files, command) = fixture("mismatch", &[]);
|
||||
let request = request();
|
||||
let failure =
|
||||
run(invocation(&request, &files, &command), &NeverCancel).expect_err("mismatch must fail");
|
||||
assert_eq!(failure.kind, InvocationFailureKind::ResponseProtocol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_full_pipes_are_drained_without_deadlock_and_capture_is_bounded() {
|
||||
let count = (9 * 1024 * 1024).to_string();
|
||||
let (_root, files, command) = fixture("flood", &[&count]);
|
||||
let request = request();
|
||||
let completed =
|
||||
run(invocation(&request, &files, &command), &NeverCancel).expect("flood invocation");
|
||||
assert!(completed.diagnostics.truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_terminates_and_reaps_the_process_group() {
|
||||
let (_root, files, command) = fixture("sleep", &["5000"]);
|
||||
let request = request();
|
||||
let failure = run(
|
||||
invocation(&request, &files, &command).with_test_watchdog(Duration::from_millis(30)),
|
||||
&NeverCancel,
|
||||
)
|
||||
.expect_err("watchdog must fire");
|
||||
assert_eq!(failure.kind, InvocationFailureKind::WatchdogTimeout);
|
||||
}
|
||||
|
||||
struct Interrupts(AtomicU32);
|
||||
|
||||
impl Cancellation for Interrupts {
|
||||
fn interrupt_count(&self) -> u32 {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_interrupt_escalates_without_waiting_for_grace() {
|
||||
let (_root, files, command) = fixture("sleep", &["5000"]);
|
||||
let request = request();
|
||||
let interrupts = Interrupts(AtomicU32::new(2));
|
||||
let before = std::time::Instant::now();
|
||||
let failure =
|
||||
run(invocation(&request, &files, &command), &interrupts).expect_err("interrupt must win");
|
||||
assert_eq!(failure.kind, InvocationFailureKind::Interrupted);
|
||||
assert!(before.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_paths_are_absolute_in_fixture() {
|
||||
let (_root, files, _command) = fixture("success", &[]);
|
||||
assert!(Path::new(&files.request_path()).is_absolute());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_rejects_a_competing_total_watchdog_value() {
|
||||
let (_root, files, command) = fixture("success", &[]);
|
||||
let mut request = request();
|
||||
request.limits.child_watchdog_seconds = positive(121);
|
||||
assert!(
|
||||
Invocation::new(
|
||||
&request,
|
||||
&files,
|
||||
&command,
|
||||
NativePhaseTimeouts {
|
||||
analysis_seconds: None,
|
||||
decompile_seconds: None,
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue