#![cfg(all(feature = "embed-helper", target_os = "linux"))]
use std::ffi::CString;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use running_process_probe::inject_via_env;
unsafe extern "C" {
fn open(path: *const std::os::raw::c_char, flags: std::os::raw::c_int) -> std::os::raw::c_int;
fn close(fd: std::os::raw::c_int) -> std::os::raw::c_int;
}
fn target_profile_dir() -> PathBuf {
let exe = std::env::current_exe().expect("current_exe");
exe.parent() .and_then(|p| p.parent()) .expect("walk up from test exe")
.to_path_buf()
}
fn build_and_locate_interposer_so() -> PathBuf {
if let Some(path) = std::env::var_os("RPO_TEST_INTERPOSER_SO") {
let so = PathBuf::from(path);
assert!(so.exists(), "RPO_TEST_INTERPOSER_SO does not exist: {so:?}");
return so;
}
let status = Command::new("cargo")
.args([
"build",
"-p",
"running-process-probe-interposer-linux",
"--features",
"test-seams",
])
.status()
.expect("spawn cargo to build interposer so");
assert!(
status.success(),
"cargo build of interposer .so failed: {status:?}"
);
let so = target_profile_dir().join("librunning_process_probe_interposer_linux.so");
assert!(
so.exists(),
"expected interposer .so at {so:?} after cargo build"
);
so
}
#[test]
fn interposer_so_fires_rpp_hook_via_ld_preload() {
let so = build_and_locate_interposer_so();
let tmp = tempfile::tempdir().expect("tempdir");
let probe_path = tmp.path().join("probe.txt");
std::fs::write(&probe_path, b"hello from slice 7d\n").expect("write probe");
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("cat {}", probe_path.display()))
.stdout(Stdio::null())
.stderr(Stdio::piped());
inject_via_env(&mut cmd, &so).expect("inject_via_env");
let mut child = cmd.spawn().expect("spawn sh+cat");
let stderr_text: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let stderr_pipe = child.stderr.take().expect("stderr piped");
let reader_text = Arc::clone(&stderr_text);
let reader = std::thread::spawn(move || {
let mut pipe = stderr_pipe;
let mut buf = [0u8; 4096];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if let Ok(mut s) = reader_text.lock() {
s.push_str(&String::from_utf8_lossy(&buf[..n]));
}
}
Err(_) => break,
}
}
});
let probe_marker = probe_path.display().to_string();
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if stderr_text
.lock()
.map(|s| s.contains("RPP_HOOK") && s.contains(&probe_marker))
.unwrap_or(false)
{
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let _ = child.kill();
let _ = child.wait();
let _ = reader.join();
let captured = stderr_text.lock().map(|s| s.clone()).unwrap_or_default();
assert!(
captured.contains("RPP_HOOK"),
"expected at least one RPP_HOOK line on the child's stderr; got: {captured:?}"
);
assert!(
captured.contains(&probe_marker),
"expected RPP_HOOK line for our probe path {probe_marker:?}; got: {captured:?}"
);
}
#[test]
fn interposer_stderr_saturation_child() {
if std::env::var_os("RPO_STDERR_SATURATION_CHILD").is_none() {
return;
}
let path = CString::new("/dev/null").expect("static path has no NUL");
for _ in 0..10_000 {
let fd = unsafe { open(path.as_ptr(), 0) };
assert!(fd >= 0, "open /dev/null failed");
assert_eq!(unsafe { close(fd) }, 0, "close /dev/null failed");
}
}
#[test]
fn interposer_hook_does_not_block_when_stderr_is_full() {
let so = build_and_locate_interposer_so();
let current_test = std::env::current_exe().expect("current test executable");
let mut cmd = Command::new(current_test);
cmd.args([
"--exact",
"interposer_stderr_saturation_child",
"--nocapture",
])
.env("RPO_STDERR_SATURATION_CHILD", "1")
.stdout(Stdio::null())
.stderr(Stdio::piped());
inject_via_env(&mut cmd, &so).expect("inject_via_env");
let mut child = cmd.spawn().expect("spawn saturation child");
let _undrained_stderr = child.stderr.take().expect("stderr piped");
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match child.try_wait().expect("poll saturation child") {
Some(status) => {
assert!(status.success(), "saturation child failed: {status}");
break;
}
None if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(20));
}
None => {
let _ = child.kill();
let _ = child.wait();
panic!("interposer blocked a hooked file operation after its stderr pipe filled");
}
}
}
}
#[test]
fn interposer_post_fork_child_progress_entrypoint() {
let Some(mode) = std::env::var_os("RPO_POST_FORK_CHILD_MODE") else {
return;
};
type HoldFn = unsafe extern "C" fn(std::os::raw::c_int, std::os::raw::c_int);
let symbol = if mode == "fd" {
c"rpo_test_hold_fd_table"
} else {
c"rpo_test_hold_renameat_resolver_init"
};
let raw = unsafe { libc::dlsym(libc::RTLD_DEFAULT, symbol.as_ptr()) };
assert!(!raw.is_null(), "missing interposer test seam");
let hold = unsafe { std::mem::transmute::<*mut libc::c_void, HoldFn>(raw) };
let mut ready = [0; 2];
let mut release = [0; 2];
assert_eq!(unsafe { libc::pipe(ready.as_mut_ptr()) }, 0);
assert_eq!(unsafe { libc::pipe(release.as_mut_ptr()) }, 0);
let holder = std::thread::spawn(move || unsafe { hold(ready[1], release[0]) });
let mut byte = [0u8; 1];
assert_eq!(
unsafe { libc::read(ready[0], byte.as_mut_ptr().cast(), 1) },
1
);
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed");
if pid == 0 {
if mode == "fd" {
unsafe { libc::close(-1) };
} else {
let missing = c"/rpo-post-fork-missing";
unsafe {
libc::renameat(
libc::AT_FDCWD,
missing.as_ptr(),
libc::AT_FDCWD,
missing.as_ptr(),
);
}
}
unsafe { libc::_exit(0) };
}
let deadline = Instant::now() + Duration::from_millis(500);
let mut status = 0;
let progressed = loop {
let waited = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
if waited == pid {
break true;
}
if Instant::now() >= deadline {
unsafe {
libc::kill(pid, libc::SIGKILL);
libc::waitpid(pid, &mut status, 0);
}
break false;
}
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(
unsafe { libc::write(release[1], byte.as_ptr().cast(), 1) },
1
);
holder.join().expect("holder joins");
assert!(
progressed,
"post-fork child blocked in {mode:?} interposer state"
);
assert!(
libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
"post-fork child terminated abnormally in {mode:?}: status={status:#x}"
);
}
#[test]
fn interposer_post_fork_child_progresses_with_inherited_locked_state() {
let so = build_and_locate_interposer_so();
let current_test = std::env::current_exe().expect("current test executable");
for mode in ["fd", "resolver"] {
let mut cmd = Command::new(¤t_test);
cmd.args([
"--exact",
"interposer_post_fork_child_progress_entrypoint",
"--nocapture",
])
.env("RPO_POST_FORK_CHILD_MODE", mode)
.stdout(Stdio::null())
.stderr(Stdio::null());
inject_via_env(&mut cmd, &so).expect("inject_via_env");
let status = cmd.status().expect("run post-fork child regression");
assert!(status.success(), "{mode} inherited-state regression failed");
}
}