commit 912f5003fe5b14d4fe1a371fa8b1b43134e6dcdd Author: claude Date: Wed May 27 21:08:28 2026 +0000 v0.1: spawn/step/cont, state.{pc,sp,registers,memory}, prelude.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..dfa3917 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,201 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "llmdbg" +version = "0.1.0" +dependencies = [ + "libc", + "nix", + "rquickjs", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + +[[package]] +name = "rquickjs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +dependencies = [ + "rquickjs-core", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +dependencies = [ + "hashbrown", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a665123 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "llmdbg" +version = "0.1.0" +edition = "2024" + +[dependencies] +nix = { version = "0.31", features = ["ptrace", "process", "uio", "signal"] } +rquickjs = { version = "0.12", features = ["std"] } +libc = "0.2" diff --git a/src/js.rs b/src/js.rs new file mode 100644 index 0000000..3e9ccb9 --- /dev/null +++ b/src/js.rs @@ -0,0 +1,403 @@ +//! QuickJS REPL: persistent context, ptrace target lives behind an Rc, +//! globals are installed once at construction. +//! +//! v0.1 surface: +//! spawn(path, ...args) -> { pid, status } +//! step(n=1) -> stop-reason string +//! continue() -> stop-reason string (exposed as `continue` and `cont`) +//! print(...args) -> writes to stdout +//! ctx -> persistent plain object +//! state.pc / .sp -> BigInt +//! state.registers -> object of BigInts +//! state.memory(addr) -> { u8(), u32(), u64(), i64(), bytes(n) } + +use crate::target::Target; +use rquickjs::function::Rest; +use rquickjs::object::Accessor; +use rquickjs::{CatchResultExt, CaughtError, Context, Ctx, Function, Object, Runtime, Value}; +use std::cell::RefCell; +use std::rc::Rc; + +/// Shared handle. All JS closures clone this and `.borrow_mut()` inside. +pub type TargetSlot = Rc>>; + +/// Result of evaluating a script. +pub struct EvalOutput { + /// Anything the script wrote via `print()`. + pub printed: String, + /// String form of the final expression's value, or empty if `undefined` / + /// the source had no trailing expression. + pub result: String, + /// Some(msg) if the eval threw. `printed` may still contain partial output. + pub error: Option, +} + +pub struct Repl { + _rt: Runtime, + ctx: Context, + target: TargetSlot, + /// Accumulates print() output during one eval. Lives in an Rc> + /// captured by the `print` closure. + print_buf: Rc>, +} + +impl Repl { + pub fn new() -> Result { + let rt = Runtime::new().map_err(|e| format!("rt: {}", e))?; + let ctx = Context::full(&rt).map_err(|e| format!("ctx: {}", e))?; + let target: TargetSlot = Rc::new(RefCell::new(None)); + let print_buf: Rc> = Rc::new(RefCell::new(String::new())); + + ctx.with(|c| -> Result<(), String> { + install_globals(c, target.clone(), print_buf.clone()) + .map_err(|e| format!("install globals: {}", e)) + })?; + + Ok(Repl { _rt: rt, ctx, target, print_buf }) + } + + /// Evaluate one script. Captures `print()` output. Always returns Ok with + /// an EvalOutput; JS exceptions become `error: Some(...)`. + pub fn eval(&mut self, source: &str) -> EvalOutput { + self.print_buf.borrow_mut().clear(); + + let mut result = String::new(); + let mut error = None; + + self.ctx.with(|c| { + match c.eval::(source).catch(&c) { + Ok(v) => { + if !v.is_undefined() { + result = value_to_display(&c, &v).unwrap_or_else(|e| format!("", e)); + } + } + Err(e) => { + error = Some(format_caught(&e)); + } + } + }); + + EvalOutput { + printed: std::mem::take(&mut *self.print_buf.borrow_mut()), + result, + error, + } + } + + /// If a target exists, return its PID so we can kill on shutdown. + pub fn target_pid(&self) -> Option { + self.target.borrow().as_ref().map(|t| t.pid()) + } +} + +/// Render a JS value as a one-line string. We delegate to the prelude's +/// `repr()` so BigInts (and BigInts nested inside objects) render correctly. +fn value_to_display<'js>(c: &Ctx<'js>, v: &Value<'js>) -> Result { + let globals = c.globals(); + let repr: Function = globals.get("repr").map_err(|e| e.to_string())?; + let s: String = repr.call((v.clone(),)).map_err(|e| e.to_string())?; + Ok(s) +} + +fn format_caught(e: &CaughtError) -> String { + match e { + CaughtError::Error(err) => format!("error: {}", err), + CaughtError::Exception(exc) => { + let msg = exc.message().unwrap_or_else(|| "".into()); + if let Some(stack) = exc.stack() { + format!("error: {}\n{}", msg, stack) + } else { + format!("error: {}", msg) + } + } + CaughtError::Value(v) => format!("error: {:?}", v), + } +} + +// --------------------------------------------------------------------------- + +fn install_globals<'js>( + c: Ctx<'js>, + target: TargetSlot, + print_buf: Rc>, +) -> rquickjs::Result<()> { + let globals = c.globals(); + + // ---------- print(...) ---------- + { + let buf = print_buf.clone(); + let f = Function::new(c.clone(), move |rest: Rest>| { + let mut s = String::new(); + for (i, v) in rest.0.iter().enumerate() { + if i > 0 { s.push(' '); } + // Best-effort String() coercion using global String. + if let Ok(string_fn) = v.ctx().globals().get::<_, Function>("String") { + if let Ok(part) = string_fn.call::<_, String>((v.clone(),)) { + s.push_str(&part); + continue; + } + } + s.push_str(""); + } + s.push('\n'); + buf.borrow_mut().push_str(&s); + })?; + globals.set("print", f)?; + } + + // ---------- ctx (persistent JS object) ---------- + { + // Only set if it doesn't already exist (so REPL re-init wouldn't clobber). + if globals.get::<_, Value>("ctx").map(|v| v.is_undefined()).unwrap_or(true) { + let obj = Object::new(c.clone())?; + globals.set("ctx", obj)?; + } + } + + // ---------- spawn(path, ...args) ---------- + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, path: String, rest: Rest| -> rquickjs::Result> { + let args: Vec = rest.0; + match Target::spawn(&path, &args) { + Ok(t) => { + let pid = t.pid(); + *target.borrow_mut() = Some(t); + let obj = Object::new(c.clone())?; + obj.set("pid", pid)?; + obj.set("status", "stopped_at_entry")?; + Ok(obj.into_value()) + } + Err(e) => Err(throw(&c, &format!("spawn failed: {}", e))), + } + })?; + globals.set("spawn", f)?; + } + + // ---------- step(n=1) ---------- + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>, n: rquickjs::function::Opt| -> rquickjs::Result { + let n = n.0.unwrap_or(1); + if n < 1 { + return Err(throw(&c, "step(n): n must be >= 1")); + } + let mut slot = target.borrow_mut(); + let t = slot.as_mut().ok_or_else(|| throw(&c, "no target attached"))?; + let mut last = None; + for _ in 0..n { + match t.step() { + Ok(r) => { + let alive = r.is_alive(); + last = Some(r); + if !alive { break; } + } + Err(e) => return Err(throw(&c, &e)), + } + } + Ok(last.map(|r| r.tag()).unwrap_or_else(|| "step".into())) + })?; + globals.set("step", f)?; + } + + // ---------- continue() / cont() ---------- + { + let target = target.clone(); + let f = Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result { + let mut slot = target.borrow_mut(); + let t = slot.as_mut().ok_or_else(|| throw(&c, "no target attached"))?; + match t.cont() { + Ok(r) => Ok(r.tag()), + Err(e) => Err(throw(&c, &e)), + } + })?; + // NOTE: `continue` is a reserved keyword in JS — `continue()` as an + // expression is a syntax error. We expose only `cont()` for v0.1. + globals.set("cont", f)?; + } + + // ---------- state ---------- + { + let state_obj = Object::new(c.clone())?; + + // state.pc — accessor returning BigInt + { + let target = target.clone(); + state_obj.prop( + "pc", + Accessor::from(move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let r = t.regs().map_err(|e| throw(&c, &e))?; + u64_to_bigint(&c, r.rip) + }), + )?; + } + + // state.sp + { + let target = target.clone(); + state_obj.prop( + "sp", + Accessor::from(move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let r = t.regs().map_err(|e| throw(&c, &e))?; + u64_to_bigint(&c, r.rsp) + }), + )?; + } + + // state.registers — accessor returning a fresh object each time + { + let target = target.clone(); + state_obj.prop( + "registers", + Accessor::from(move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let r = t.regs().map_err(|e| throw(&c, &e))?; + let obj = Object::new(c.clone())?; + let set = |name: &str, val: u64| -> rquickjs::Result<()> { + obj.set(name, u64_to_bigint(&c, val)?) + }; + set("rax", r.rax)?; set("rbx", r.rbx)?; set("rcx", r.rcx)?; + set("rdx", r.rdx)?; set("rsi", r.rsi)?; set("rdi", r.rdi)?; + set("rbp", r.rbp)?; set("rsp", r.rsp)?; set("rip", r.rip)?; + set("r8", r.r8)?; set("r9", r.r9)?; set("r10", r.r10)?; + set("r11", r.r11)?; set("r12", r.r12)?; set("r13", r.r13)?; + set("r14", r.r14)?; set("r15", r.r15)?; + set("eflags", r.eflags)?; + Ok(obj.into_value()) + }), + )?; + } + + // state.memory(addr) → object with u8/u32/u64/i64/bytes + { + let target = target.clone(); + let mem_fn = Function::new(c.clone(), move |c: Ctx<'js>, addr: Value<'js>| -> rquickjs::Result> { + let addr = coerce_addr(&c, &addr)?; + let target = target.clone(); + let obj = Object::new(c.clone())?; + + // u8() + { + let target = target.clone(); + obj.set("u8", Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let b = t.read_mem(addr, 1).map_err(|e| throw(&c, &e))?; + Ok(b[0] as u32) + })?)?; + } + // u32() + { + let target = target.clone(); + obj.set("u32", Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let b = t.read_mem(addr, 4).map_err(|e| throw(&c, &e))?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + })?)?; + } + // u64() → BigInt + { + let target = target.clone(); + obj.set("u64", Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let b = t.read_mem(addr, 8).map_err(|e| throw(&c, &e))?; + let mut a = [0u8; 8]; a.copy_from_slice(&b); + let v = u64::from_le_bytes(a); + u64_to_bigint(&c, v) + })?)?; + } + // i64() → BigInt + { + let target = target.clone(); + obj.set("i64", Function::new(c.clone(), move |c: Ctx<'js>| -> rquickjs::Result> { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let b = t.read_mem(addr, 8).map_err(|e| throw(&c, &e))?; + let mut a = [0u8; 8]; a.copy_from_slice(&b); + let v = i64::from_le_bytes(a); + i64_to_bigint(&c, v) + })?)?; + } + // bytes(n) → hex string + { + let target = target.clone(); + obj.set("bytes", Function::new(c.clone(), move |c: Ctx<'js>, n: usize| -> rquickjs::Result { + let slot = target.borrow(); + let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?; + let b = t.read_mem(addr, n).map_err(|e| throw(&c, &e))?; + let mut s = String::with_capacity(n * 2); + for byte in &b { s.push_str(&format!("{:02x}", byte)); } + Ok(s) + })?)?; + } + + Ok(obj.into_value()) + })?; + state_obj.set("memory", mem_fn)?; + } + + globals.set("state", state_obj)?; + } + + // ---------- JS-side prelude --------------------------------------------- + // Loaded last, so it can reference any of the native globals above. + // Lives in src/prelude.js, embedded at compile time. + const PRELUDE: &str = include_str!("prelude.js"); + c.eval::<(), _>(PRELUDE)?; + + Ok(()) +} + +// --- helpers -------------------------------------------------------------- + +/// Create a JS Error and turn it into an rquickjs::Error::Exception. The idiom +/// in rquickjs for "throw an error from a Rust callback" is to call +/// `Exception::throw_*(ctx, msg)`. +fn throw(c: &Ctx<'_>, msg: &str) -> rquickjs::Error { + rquickjs::Exception::throw_message(c, msg) +} + +/// Accept either a JS Number or BigInt and produce a u64 address. +fn coerce_addr<'js>(c: &Ctx<'js>, v: &Value<'js>) -> rquickjs::Result { + if let Some(n) = v.as_number() { + if n < 0.0 || !n.is_finite() { + return Err(throw(c, "address must be a non-negative finite number")); + } + return Ok(n as u64); + } + if v.is_int() { + let i: i32 = v.get()?; + if i < 0 { return Err(throw(c, "address must be non-negative")); } + return Ok(i as u64); + } + // Try BigInt: route through global BigInt -> toString(10) -> parse. + let globals = c.globals(); + let string_fn: Function = globals.get("String")?; + let s: String = string_fn.call((v.clone(),)).map_err(|_| throw(c, "bad address"))?; + // BigInt String() form is like "1234567" (no n suffix). + s.parse::().map_err(|_| throw(c, &format!("bad address: {}", s))) +} + +/// Convert a u64 to a JS BigInt by evaluating `BigInt("")` in the context. +/// Not the fastest, but completely robust and avoids hunting the rquickjs API +/// surface in v0.1. +fn u64_to_bigint<'js>(c: &Ctx<'js>, v: u64) -> rquickjs::Result> { + let globals = c.globals(); + let bi: Function = globals.get("BigInt")?; + let s = v.to_string(); + bi.call((s,)) +} + +fn i64_to_bigint<'js>(c: &Ctx<'js>, v: i64) -> rquickjs::Result> { + let globals = c.globals(); + let bi: Function = globals.get("BigInt")?; + let s = v.to_string(); + bi.call((s,)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fc88206 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,82 @@ +//! llmdbg v0.1 — stdin/stdout REPL daemon. +//! +//! Protocol: +//! - Read lines from stdin until a line is exactly `---END---`. +//! - Evaluate the accumulated buffer as one JS script in a persistent context. +//! - Print any `print()` output, then the script's result expression (if not +//! undefined), then a line `---DONE---`. On error, print `error: ...` +//! before `---DONE---`. +//! - Repeat. +//! - EOF on stdin exits cleanly. + +mod target; +mod js; + +use std::io::{BufRead, Write}; + +const SCRIPT_END: &str = "---END---"; +const RESPONSE_END: &str = "---DONE---"; + +fn main() { + let mut repl = match js::Repl::new() { + Ok(r) => r, + Err(e) => { + eprintln!("repl init failed: {}", e); + std::process::exit(1); + } + }; + + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + let mut stdin = stdin.lock(); + let mut stdout = stdout.lock(); + + let mut script = String::new(); + let mut line = String::new(); + + loop { + line.clear(); + let n = match stdin.read_line(&mut line) { + Ok(n) => n, + Err(e) => { eprintln!("read error: {}", e); break; } + }; + if n == 0 { + // EOF. + if !script.is_empty() { + // Treat trailing buffer as final script. + eval_and_print(&mut repl, &script, &mut stdout); + } + break; + } + + let stripped = line.trim_end_matches(|c| c == '\n' || c == '\r'); + if stripped == SCRIPT_END { + eval_and_print(&mut repl, &script, &mut stdout); + script.clear(); + } else { + script.push_str(&line); + } + } + + // Shutdown: if a target is still alive, try to kill it so we don't leak. + if let Some(pid) = repl.target_pid() { + let _ = nix::sys::signal::kill( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGKILL, + ); + } +} + +fn eval_and_print(repl: &mut js::Repl, src: &str, out: &mut impl Write) { + let r = repl.eval(src); + if !r.printed.is_empty() { + let _ = out.write_all(r.printed.as_bytes()); + } + if let Some(e) = &r.error { + let _ = writeln!(out, "{}", e); + } else if !r.result.is_empty() { + let _ = writeln!(out, "{}", r.result); + } + let _ = writeln!(out, "{}", RESPONSE_END); + let _ = out.flush(); +} diff --git a/src/prelude.js b/src/prelude.js new file mode 100644 index 0000000..5632dbb --- /dev/null +++ b/src/prelude.js @@ -0,0 +1,87 @@ +// llmdbg prelude. +// +// Loaded once at REPL startup, after the native (Rust-backed) globals +// (spawn, step, cont, print, state, ctx, ...) are installed. Anything that +// can be expressed in plain JS belongs here, not in Rust. +// +// Conventions: +// - Attach exports to globalThis. +// - Keep names short; this is a debugger REPL, not a library. +// - Don't capture state in module scope — the REPL has one persistent +// context, but `ctx` is the user's scratchpad. We don't touch it. + +// --------------------------------------------------------------------------- +// hex / unhex — Number or BigInt → "0x..." and back. +// +// hex(v): +// BigInt → "0x" + v.toString(16) +// non-negative Number (finite) → same, after truncation +// anything else → coerced via BigInt(v); throws if that fails +// +// unhex(s): +// "0xff" | "ff" | "0XFF" → BigInt(255) +// throws on anything else + +globalThis.hex = function (v) { + if (typeof v === "bigint") { + if (v < 0n) return "-0x" + (-v).toString(16); + return "0x" + v.toString(16); + } + if (typeof v === "number") { + if (!Number.isFinite(v)) throw new Error("hex(): non-finite number"); + if (v < 0) return "-0x" + Math.trunc(-v).toString(16); + return "0x" + Math.trunc(v).toString(16); + } + // Last resort: try BigInt coercion (handles boolean, numeric strings). + return hex(BigInt(v)); +}; + +globalThis.unhex = function (s) { + if (typeof s !== "string") throw new Error("unhex(): expected string"); + let neg = false; + let t = s.trim(); + if (t.startsWith("-")) { neg = true; t = t.slice(1); } + if (t.startsWith("0x") || t.startsWith("0X")) t = t.slice(2); + if (!/^[0-9a-fA-F]+$/.test(t)) throw new Error("unhex(): bad hex: " + s); + const v = BigInt("0x" + t); + return neg ? -v : v; +}; + +// JSON.stringify replacer that turns BigInt into "0x..." so nested addresses +// don't crash the serializer. Used by repr() and dump(). +function _bigintReplacer(_key, value) { + if (typeof value === "bigint") return hex(value); + return value; +} + +// --------------------------------------------------------------------------- +// dump(label, v) — terse one-line formatter for inspection during a step loop. +// Numbers and BigInts come out as hex; everything else gets the default +// JS string coercion. Designed for token-efficient log lines. + +globalThis.dump = function (label, v) { + let s; + if (typeof v === "bigint" || typeof v === "number") { + s = hex(v); + } else if (v === null || v === undefined) { + s = String(v); + } else if (typeof v === "object") { + try { s = JSON.stringify(v, _bigintReplacer); } catch (_) { s = String(v); } + } else { + s = String(v); + } + print(label + "\t" + s); +}; + +// --------------------------------------------------------------------------- +// repr(v) — like hex() for numerics, JSON for objects, String() otherwise. +// Useful as a building block when you want a single value rendered. + +globalThis.repr = function (v) { + if (typeof v === "bigint" || typeof v === "number") return hex(v); + if (v === null || v === undefined) return String(v); + if (typeof v === "object") { + try { return JSON.stringify(v, _bigintReplacer); } catch (_) { return String(v); } + } + return String(v); +}; diff --git a/src/target.rs b/src/target.rs new file mode 100644 index 0000000..ab92d26 --- /dev/null +++ b/src/target.rs @@ -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 { + let c_path = CString::new(path).map_err(|e| format!("bad path: {}", e))?; + let mut c_args: Vec = 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 { + 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 { + 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 { + 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 { + 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, 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); + } + } +}