ghidra-cli/tests/cli.rs
2026-07-28 22:20:39 +00:00

308 lines
11 KiB
Rust

#![allow(clippy::expect_used)]
#![doc = "Black-box CLI framing and exit-status tests."]
use std::{
fs,
os::unix::fs::PermissionsExt as _,
process::{Command, Stdio},
thread,
time::Duration,
};
use rustix::process::{Pid, Signal, kill_process};
#[test]
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"))
.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(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(&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]
fn invalid_invocation_is_one_json_document_on_stderr() {
let output = Command::new(env!("CARGO_BIN_EXE_ghidr"))
.args(["functions", "sample", "--limit", "0"])
.output()
.expect("run ghidr");
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
let error: serde_json::Value =
serde_json::from_slice(&output.stderr).expect("valid JSON error");
assert_eq!(error["error"]["code"], "invalid_arguments");
assert_eq!(output.stderr.last(), Some(&b'\n'));
}
#[test]
fn explicit_human_parse_failure_is_not_json() {
let output = Command::new(env!("CARGO_BIN_EXE_ghidr"))
.args(["--format", "human", "decompile", "sample"])
.output()
.expect("run ghidr");
assert_eq!(output.status.code(), Some(2));
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
);
}