v0.3: ELF symbol parsing, sym()/addr()/symbols() globals, addrstr/here prelude helpers

This commit is contained in:
claude
2026-05-27 21:13:10 +00:00
parent 19c6154831
commit d587dbcd4c
7 changed files with 304 additions and 5 deletions
Generated
+16
View File
@@ -77,9 +77,16 @@ version = "0.1.0"
dependencies = [
"libc",
"nix",
"object",
"rquickjs",
]
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "nix"
version = "0.31.3"
@@ -92,6 +99,15 @@ dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
+1
View File
@@ -7,3 +7,4 @@ edition = "2024"
nix = { version = "0.31", features = ["ptrace", "process", "uio", "signal"] }
rquickjs = { version = "0.12", features = ["std"] }
libc = "0.2"
object = { version = "0.36", default-features = false, features = ["read", "std"] }
+47
View File
@@ -318,6 +318,53 @@ fn install_globals<'js>(
globals.set("prev", prev_obj)?;
}
// ---------- sym(addr) -> "name+0xN" | null ----------
{
let target = target.clone();
let f = Function::new(c.clone(), move |c: Ctx<'js>, addr: Value<'js>| -> rquickjs::Result<Value<'js>> {
let addr = coerce_addr(&c, &addr)?;
let slot = target.borrow();
let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?;
let s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?;
match s.name_for_addr(addr) {
Some(name) => Ok(rquickjs::String::from_str(c.clone(), &name)?.into_value()),
None => Ok(Value::new_null(c.clone())),
}
})?;
globals.set("sym", f)?;
}
// ---------- addr(name) -> BigInt | null ----------
{
let target = target.clone();
let f = Function::new(c.clone(), move |c: Ctx<'js>, name: String| -> rquickjs::Result<Value<'js>> {
let slot = target.borrow();
let t = slot.as_ref().ok_or_else(|| throw(&c, "no target attached"))?;
let s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?;
match s.addr_for_name(&name) {
Some(a) => u64_to_bigint(&c, a),
None => Ok(Value::new_null(c.clone())),
}
})?;
globals.set("addr", f)?;
}
// ---------- symbols() -> { count, load_base, path } ----------
{
let target = target.clone();
let f = 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 s = t.symbols().ok_or_else(|| throw(&c, "no symbol table"))?;
let obj = Object::new(c.clone())?;
obj.set("count", s.len() as u32)?;
obj.set("load_base", u64_to_bigint(&c, s.load_base)?)?;
obj.set("path", s.path.to_string_lossy().into_owned())?;
Ok(obj.into_value())
})?;
globals.set("symbols", f)?;
}
// ---------- JS-side prelude ---------------------------------------------
// Loaded last, so it can reference any of the native globals above.
// Lives in src/prelude.js, embedded at compile time.
+1
View File
@@ -10,6 +10,7 @@
//! - EOF on stdin exits cleanly.
mod target;
mod symbols;
mod js;
use std::io::{BufRead, Write};
+26
View File
@@ -85,3 +85,29 @@ globalThis.repr = function (v) {
}
return String(v);
};
// ---------------------------------------------------------------------------
// addrstr(v) — render an address with symbol context.
// Returns "0x7fff... (main+0x4)" if the address is inside a known symbol,
// or just "0x7fff..." otherwise. Safe to call before a target is attached:
// falls back to bare hex if `sym` throws.
globalThis.addrstr = function (v) {
const h = hex(v);
try {
const s = sym(v);
return s ? `${h} (${s})` : h;
} catch (_) {
return h;
}
};
// ---------------------------------------------------------------------------
// here() — quick "where am I" one-liner. Prints pc with symbol context.
globalThis.here = function (label) {
const s = addrstr(state.pc);
if (label) print(label, s); else print(s);
};
+195
View File
@@ -0,0 +1,195 @@
//! ELF symbol table parsing and address lookups for the main executable.
//!
//! v0.3 scope:
//! - Parse the main binary's symbol table (.symtab + .dynsym).
//! - Handle PIE (ET_DYN) by reading the load base from /proc/<pid>/maps.
//! - Provide name → addr and addr → "name+0xoff" lookups.
//! - Shared libraries are NOT covered — those need ld.so awareness and live
//! in a later milestone.
use object::{Object, ObjectSymbol, ObjectSection, SymbolKind};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
/// One named symbol with a load-base-adjusted virtual address and a size.
/// Size==0 is common (assembly globals etc.); we treat 0 as "unknown size,
/// match only by exact start address" when doing addr → name lookups.
#[derive(Debug, Clone)]
pub struct Symbol {
pub name: String,
pub addr: u64,
pub size: u64,
}
pub struct SymbolTable {
/// addr → symbol. Keys are post-relocation virtual addresses.
by_addr: BTreeMap<u64, Symbol>,
/// Lowercase name → addr. We keep the case-sensitive name in Symbol but
/// allow case-insensitive lookups since CLI debugger users won't always
/// remember exact capitalisation. Stores the *first* address seen for
/// a name (binaries can have duplicates after stripping).
by_name: BTreeMap<String, u64>,
/// Load base offset that was applied to file-relative addresses. 0 for
/// non-PIE binaries.
pub load_base: u64,
/// Path the symbols came from, for diagnostics.
pub path: PathBuf,
}
impl SymbolTable {
/// Load symbols from /proc/<pid>/exe, applying the load base from
/// /proc/<pid>/maps if the binary is PIE.
pub fn from_pid(pid: i32) -> Result<SymbolTable, String> {
let exe_path = read_exe_path(pid)?;
let load_base = read_load_base(pid, &exe_path)?;
let bytes = fs::read(&exe_path)
.map_err(|e| format!("read exe {}: {}", exe_path.display(), e))?;
let elf = object::File::parse(&*bytes)
.map_err(|e| format!("parse ELF: {}", e))?;
let mut by_addr: BTreeMap<u64, Symbol> = BTreeMap::new();
let mut by_name: BTreeMap<String, u64> = BTreeMap::new();
for sym in elf.symbols().chain(elf.dynamic_symbols()) {
if !matches!(sym.kind(), SymbolKind::Text | SymbolKind::Data | SymbolKind::Unknown) {
continue;
}
let raw_name = match sym.name() {
Ok(n) if !n.is_empty() => n,
_ => continue,
};
// Filter out section-undefined symbols (imports).
if sym.section_index().is_none() && !sym.is_definition() {
continue;
}
let addr = sym.address().wrapping_add(load_base);
if addr == 0 { continue; }
let s = Symbol {
name: raw_name.to_string(),
addr,
size: sym.size(),
};
// Prefer the entry already there if both exist at same address
// and the newer one has size 0 — keeps size info around.
match by_addr.get(&addr) {
Some(existing) if existing.size > 0 && s.size == 0 => { /* keep existing */ }
_ => { by_addr.insert(addr, s.clone()); }
}
by_name.entry(raw_name.to_string()).or_insert(addr);
// Also stash a lowercase alias if it isn't already a key
let lc = raw_name.to_lowercase();
if lc != raw_name {
by_name.entry(lc).or_insert(addr);
}
}
// For diagnostic: also note executable section ranges (might help us
// later filter "is this addr even code"). Not used yet but cheap.
let _exec_sections: Vec<_> = elf.sections()
.filter(|s| s.kind() == object::SectionKind::Text)
.map(|s| (s.address().wrapping_add(load_base), s.size()))
.collect();
Ok(SymbolTable { by_addr, by_name, load_base, path: exe_path })
}
/// Find the symbol containing or starting at `addr`.
/// Returns "name" if exact, "name+0xN" if interior, None if no match.
pub fn name_for_addr(&self, addr: u64) -> Option<String> {
// Look at the symbol whose addr ≤ ours, and check whether ours falls
// within [sym.addr, sym.addr + sym.size). For size==0 symbols, only
// match an exact equality.
let (&_, sym) = self.by_addr.range(..=addr).next_back()?;
let off = addr - sym.addr;
if sym.size == 0 {
if off == 0 { Some(sym.name.clone()) } else { None }
} else if off < sym.size {
if off == 0 { Some(sym.name.clone()) } else { Some(format!("{}+0x{:x}", sym.name, off)) }
} else {
None
}
}
/// Resolve a name to a single address. Tries exact case first, then
/// lowercase. Returns None if not found or ambiguous.
pub fn addr_for_name(&self, name: &str) -> Option<u64> {
if let Some(&a) = self.by_name.get(name) { return Some(a); }
self.by_name.get(&name.to_lowercase()).copied()
}
pub fn len(&self) -> usize {
self.by_addr.len()
}
}
fn read_exe_path(pid: i32) -> Result<PathBuf, String> {
fs::read_link(format!("/proc/{}/exe", pid))
.map_err(|e| format!("read /proc/{}/exe: {}", pid, e))
}
/// For a PIE (ET_DYN) binary, find the first executable mapping of the exe
/// file in /proc/<pid>/maps and use its address as the load base. For non-PIE
/// (ET_EXEC), the load base is 0.
fn read_load_base(pid: i32, exe_path: &PathBuf) -> Result<u64, String> {
// Peek at the ELF header e_type via the file we'll parse anyway.
let bytes = fs::read(exe_path)
.map_err(|e| format!("read exe {}: {}", exe_path.display(), e))?;
let elf = object::File::parse(&*bytes)
.map_err(|e| format!("parse ELF: {}", e))?;
// object doesn't expose e_type directly on the high-level File, but
// it does expose `relative_address_base` which is what we want: for
// ET_EXEC this is the absolute base of the file's address space,
// for ET_DYN it's 0 (so symbol addresses are file-offsets-as-vaddrs).
// We compute load base = first executable mapping start - (file's PT_LOAD
// p_vaddr for that segment). Easier: parse /proc/maps and look for the
// first executable mapping whose pathname matches exe_path.
let exe_str = exe_path.to_string_lossy().into_owned();
let maps = fs::read_to_string(format!("/proc/{}/maps", pid))
.map_err(|e| format!("read /proc/{}/maps: {}", pid, e))?;
// /proc/<pid>/maps line format:
// ADDR_LO-ADDR_HI PERMS OFFSET DEV INODE PATH
// We want the first line where PATH matches our exe AND PERMS contains 'x'.
for line in maps.lines() {
// Split into at most 6 whitespace-delimited fields; the last is the
// path which may itself contain spaces (unlikely in practice).
let mut it = line.splitn(6, ' ').filter(|s| !s.is_empty());
let range = match it.next() { Some(s) => s, None => continue };
let perms = match it.next() { Some(s) => s, None => continue };
let _offset = it.next();
let _dev = it.next();
let _inode = it.next();
let path = it.next().unwrap_or("").trim();
if !perms.contains('x') { continue; }
if path != exe_str { continue; }
let mut parts = range.split('-');
let lo = parts.next().ok_or_else(|| "bad maps line".to_string())?;
let lo = u64::from_str_radix(lo, 16).map_err(|e| format!("bad maps addr: {}", e))?;
// For ET_DYN this is the load base directly (file offset 0 maps to lo).
// For ET_EXEC, file vaddrs are absolute, and lo equals the first
// PT_LOAD's p_vaddr, so we want 0.
if is_pie(&elf) {
// We assume the first executable mapping starts at file offset 0
// for the .text segment — usually true. If we wanted to be careful
// we'd subtract the segment's file offset, but it's normally 0
// for the first executable PT_LOAD.
return Ok(lo);
} else {
return Ok(0);
}
}
// Fallback: no executable mapping found. Probably never happens.
Ok(0)
}
fn is_pie(elf: &object::File) -> bool {
// ET_DYN is what gcc produces by default for executables ("-pie") and
// also for shared libraries. For an executable file, ET_DYN ≡ PIE.
match elf {
object::File::Elf64(e) => e.elf_header().e_type.get(object::Endianness::Little) == object::elf::ET_DYN,
object::File::Elf32(e) => e.elf_header().e_type.get(object::Endianness::Little) == object::elf::ET_DYN,
_ => false,
}
}
+18 -5
View File
@@ -11,6 +11,8 @@ use nix::unistd::{execvp, fork, ForkResult, Pid};
use std::ffi::CString;
use std::io::IoSliceMut;
use crate::symbols::SymbolTable;
/// Why the target stopped after a step/continue, or that it has exited.
#[derive(Debug, Clone)]
pub enum StopReason {
@@ -55,6 +57,9 @@ pub struct Target {
/// Snapshot of registers from the previous stop. None until at least one
/// step/cont has happened after spawn.
prev_regs: Option<libc::user_regs_struct>,
/// ELF symbols of the main executable. None if parsing failed (e.g.
/// stripped binary, unreadable /proc) — symbol lookups become no-ops.
symbols: Option<SymbolTable>,
}
impl Target {
@@ -102,11 +107,15 @@ impl Target {
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),
prev_regs: None,
}),
WaitStatus::Stopped(_, Signal::SIGTRAP) => {
let symbols = SymbolTable::from_pid(child.as_raw()).ok();
Ok(Target {
pid: child,
state: State::Stopped(StopReason::Step),
prev_regs: None,
symbols,
})
}
WaitStatus::Exited(_, code) => {
Err(format!("child exited immediately with code {} (exec failed?)", code))
}
@@ -143,6 +152,10 @@ impl Target {
self.prev_regs
}
pub fn symbols(&self) -> Option<&SymbolTable> {
self.symbols.as_ref()
}
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 {