v0.1: spawn/step/cont, state.{pc,sp,registers,memory}, prelude.js

This commit is contained in:
claude
2026-05-27 21:08:28 +00:00
commit 912f5003fe
7 changed files with 971 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
//! Linux x86-64 ptrace wrapper for v0.1.
//!
//! Spawn-only, single-threaded debuggee. The `Target` owns the child PID
//! and tracks whether the child is currently stopped or has exited.
use nix::sys::ptrace;
use nix::sys::signal::Signal;
use nix::sys::uio::{process_vm_readv, RemoteIoVec};
use nix::sys::wait::{waitpid, WaitStatus};
use nix::unistd::{execvp, fork, ForkResult, Pid};
use std::ffi::CString;
use std::io::IoSliceMut;
/// Why the target stopped after a step/continue, or that it has exited.
#[derive(Debug, Clone)]
pub enum StopReason {
/// Hit a SIGTRAP from single-stepping (or, in future, a breakpoint).
Step,
/// Caught some other signal — we don't yet inject it back, just report.
Signal(Signal),
/// Process exited normally with this code.
Exited(i32),
/// Process was killed by a signal.
Killed(Signal),
}
impl StopReason {
/// Short tag for the REPL caller to print.
pub fn tag(&self) -> String {
match self {
StopReason::Step => "step".into(),
StopReason::Signal(s) => format!("signal:{}", s),
StopReason::Exited(c) => format!("exited:{}", c),
StopReason::Killed(s) => format!("killed:{}", s),
}
}
pub fn is_alive(&self) -> bool {
matches!(self, StopReason::Step | StopReason::Signal(_))
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum State {
/// Stopped, ready to read regs/memory and step.
Stopped(StopReason),
/// Dead. Subsequent operations should fail cleanly.
Dead(StopReason),
}
pub struct Target {
pid: Pid,
state: State,
}
impl Target {
pub fn pid(&self) -> i32 {
self.pid.as_raw()
}
#[allow(dead_code)]
pub fn state(&self) -> &State {
&self.state
}
/// True if the target is currently stopped and inspectable.
pub fn is_stopped(&self) -> bool {
matches!(self.state, State::Stopped(_))
}
/// fork() + PTRACE_TRACEME + execvp(). Returns once the child has been
/// caught at its initial post-exec SIGTRAP.
pub fn spawn(path: &str, args: &[String]) -> Result<Target, String> {
let c_path = CString::new(path).map_err(|e| format!("bad path: {}", e))?;
let mut c_args: Vec<CString> = Vec::with_capacity(args.len() + 1);
c_args.push(c_path.clone());
for a in args {
c_args.push(CString::new(a.as_str()).map_err(|e| format!("bad arg: {}", e))?);
}
// SAFETY: fork() must only call async-signal-safe functions in the
// child. We use ptrace::traceme and execvp, both fine.
let fork_result = unsafe { fork() }.map_err(|e| format!("fork failed: {}", e))?;
match fork_result {
ForkResult::Child => {
// In the child. Anything that goes wrong here we report via
// exit code; the parent will see the abnormal exit on waitpid.
if ptrace::traceme().is_err() {
unsafe { libc::_exit(127) };
}
// execvp replaces the process image; on success it does not return.
let _ = execvp(&c_path, &c_args);
unsafe { libc::_exit(127) };
}
ForkResult::Parent { child } => {
// Wait for the initial post-exec SIGTRAP.
let status = waitpid(child, None)
.map_err(|e| format!("waitpid (initial) failed: {}", e))?;
match status {
WaitStatus::Stopped(_, Signal::SIGTRAP) => Ok(Target {
pid: child,
state: State::Stopped(StopReason::Step),
}),
WaitStatus::Exited(_, code) => {
Err(format!("child exited immediately with code {} (exec failed?)", code))
}
other => Err(format!("unexpected initial wait status: {:?}", other)),
}
}
}
}
/// PTRACE_SINGLESTEP + waitpid. Updates internal state.
pub fn step(&mut self) -> Result<StopReason, String> {
if !self.is_stopped() {
return Err("target is not stopped".into());
}
ptrace::step(self.pid, None).map_err(|e| format!("ptrace step: {}", e))?;
let reason = self.wait_for_stop()?;
Ok(reason)
}
/// PTRACE_CONT + waitpid.
pub fn cont(&mut self) -> Result<StopReason, String> {
if !self.is_stopped() {
return Err("target is not stopped".into());
}
ptrace::cont(self.pid, None).map_err(|e| format!("ptrace cont: {}", e))?;
let reason = self.wait_for_stop()?;
Ok(reason)
}
fn wait_for_stop(&mut self) -> Result<StopReason, String> {
let status = waitpid(self.pid, None).map_err(|e| format!("waitpid: {}", e))?;
let reason = match status {
WaitStatus::Stopped(_, Signal::SIGTRAP) => StopReason::Step,
WaitStatus::Stopped(_, sig) => StopReason::Signal(sig),
WaitStatus::Exited(_, code) => StopReason::Exited(code),
WaitStatus::Signaled(_, sig, _) => StopReason::Killed(sig),
other => return Err(format!("unexpected wait status: {:?}", other)),
};
self.state = if reason.is_alive() {
State::Stopped(reason.clone())
} else {
State::Dead(reason.clone())
};
Ok(reason)
}
pub fn regs(&self) -> Result<libc::user_regs_struct, String> {
if !self.is_stopped() {
return Err("target is not stopped".into());
}
ptrace::getregs(self.pid).map_err(|e| format!("getregs: {}", e))
}
/// Read `len` bytes from the target's memory at `addr` via process_vm_readv.
/// Much faster than peek loops for >8 bytes, and works for any size.
pub fn read_mem(&self, addr: u64, len: usize) -> Result<Vec<u8>, String> {
if !self.is_stopped() {
return Err("target is not stopped".into());
}
let mut buf = vec![0u8; len];
let mut local = [IoSliceMut::new(&mut buf)];
let remote = [RemoteIoVec { base: addr as usize, len }];
let n = process_vm_readv(self.pid, &mut local, &remote)
.map_err(|e| format!("read_mem(0x{:x}, {}): {}", addr, len, e))?;
if n != len {
return Err(format!(
"short read at 0x{:x}: got {} of {}",
addr, n, len
));
}
Ok(buf)
}
}
impl Drop for Target {
fn drop(&mut self) {
// Best effort: if the target is still alive, detach so we don't leave
// it stopped. If it's already dead, nothing to do.
if self.is_stopped() {
let _ = ptrace::detach(self.pid, None);
}
}
}