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
+1
View File
@@ -0,0 +1 @@
/target
Generated
+201
View File
@@ -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"
+9
View File
@@ -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"
+403
View File
@@ -0,0 +1,403 @@
//! QuickJS REPL: persistent context, ptrace target lives behind an Rc<RefCell>,
//! 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<RefCell<Option<Target>>>;
/// 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<String>,
}
pub struct Repl {
_rt: Runtime,
ctx: Context,
target: TargetSlot,
/// Accumulates print() output during one eval. Lives in an Rc<RefCell<String>>
/// captured by the `print` closure.
print_buf: Rc<RefCell<String>>,
}
impl Repl {
pub fn new() -> Result<Self, String> {
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<RefCell<String>> = 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::<Value, _>(source).catch(&c) {
Ok(v) => {
if !v.is_undefined() {
result = value_to_display(&c, &v).unwrap_or_else(|e| format!("<display err: {}>", 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<i32> {
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<String, String> {
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(|| "<no message>".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<RefCell<String>>,
) -> rquickjs::Result<()> {
let globals = c.globals();
// ---------- print(...) ----------
{
let buf = print_buf.clone();
let f = Function::new(c.clone(), move |rest: Rest<Value<'js>>| {
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<String>| -> rquickjs::Result<Value<'js>> {
let args: Vec<String> = 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<i64>| -> rquickjs::Result<String> {
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<String> {
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<Value<'js>> {
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<Value<'js>> {
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<Value<'js>> {
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<Value<'js>> {
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<u32> {
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<u32> {
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<Value<'js>> {
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<Value<'js>> {
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<String> {
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<u64> {
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::<u64>().map_err(|_| throw(c, &format!("bad address: {}", s)))
}
/// Convert a u64 to a JS BigInt by evaluating `BigInt("<dec>")` 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<Value<'js>> {
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<Value<'js>> {
let globals = c.globals();
let bi: Function = globals.get("BigInt")?;
let s = v.to_string();
bi.call((s,))
}
+82
View File
@@ -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();
}
+87
View File
@@ -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);
};
+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);
}
}
}