ghidra-cli/tests/worker_lifecycle.rs

187 lines
5.9 KiB
Rust

#![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()
);
}