feat: implement worker lifecycle and sandbox
This commit is contained in:
parent
170ca32f58
commit
b9b9bfe05a
13 changed files with 2456 additions and 7 deletions
114
tests/bubblewrap_probe.rs
Normal file
114
tests/bubblewrap_probe.rs
Normal file
|
|
@ -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<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.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())
|
||||
})
|
||||
}
|
||||
118
tests/support/fake_child.rs
Normal file
118
tests/support/fake_child.rs
Normal file
|
|
@ -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<dyn std::error::Error>> {
|
||||
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::<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 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": {
|
||||
"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(())
|
||||
}
|
||||
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