naksheap: reconstruct heap object graphs from core dumps

Rust workspace that turns a core dump of a stripped, optimized C/C++
binary into a typed heap object graph: objects, sizes, allocator state,
references, and probable struct layouts, all without debug info.

- glibc ptmalloc carving (main + thread arenas, tcache/fastbin freed
  state, mmap allocations), ELF core + memory-list minidump parsing
- pointer scan, layout clustering, vtable/string/vector detection,
  confidence + evidence on every node
- ASCII/JSON/Graphviz/HTML output, synthetic fixtures with ground truth,
  and real-dump validation harness (aarch64 glibc 2.39) in scripts/
- web deployment reference stack in deploy/

MIT OR Apache-2.0
This commit is contained in:
lakshit verma 2026-08-15 04:21:59 +05:30
commit a48b163683
No known key found for this signature in database
59 changed files with 10518 additions and 0 deletions

View file

@ -0,0 +1,18 @@
[package]
name = "naksheap-viz"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Visualization: ASCII tree, Graphviz dot, HTML/cytoscape viewer (graph JSON embedded, cytoscape.js from CDN)"
[dependencies]
naksheap-inference = { path = "../naksheap-inference" }
serde.workspace = true
serde_json.workspace = true
[dev-dependencies]
naksheap-core-parse = { path = "../naksheap-core-parse" }
naksheap-allocator-heuristics = { path = "../naksheap-allocator-heuristics" }
naksheap-pointer-scan = { path = "../naksheap-pointer-scan" }
naksheap-testkit = { path = "../naksheap-testkit" }

View file

@ -0,0 +1,224 @@
//! Root-anchored ASCII tree rendering of the object graph.
use std::collections::{HashMap, HashSet};
use naksheap_inference::types::GraphEdge;
use naksheap_inference::{Field, FieldKind, Node, ObjectGraph, ObjectLabel};
/// Maximum number of unreachable objects listed in the trailing section before
/// the remainder is collapsed into a count.
const MAX_UNREACHABLE_LISTED: usize = 20;
/// Renders `graph` as a root-anchored ASCII tree starting at root nodes
/// (nodes referenced by a register/stack edge); falls back to the highest
/// inbound node when no root exists. Recursion over outgoing object edges is
/// bounded by `max_depth` and guarded against cycles. A trailing section lists
/// carved objects that no root could reach, so consumers never silently miss
/// objects.
pub fn ascii_tree(graph: &ObjectGraph, max_depth: usize) -> String {
// O(1) addr -> node lookup, replacing the per-edge linear scan.
let node_by_addr: HashMap<u64, &Node> =
graph.nodes.iter().map(|n| (n.addr, n)).collect();
// Pre-index outgoing object edges by source (`from != 0`) so each node is
// rendered in O(outgoing) instead of a full edge-list scan per node.
let mut outgoing: HashMap<u64, Vec<&GraphEdge>> = HashMap::new();
for e in &graph.edges {
if e.from != 0 {
outgoing.entry(e.from).or_default().push(e);
}
}
let mut w = TreeWriter {
outgoing: &outgoing,
node_by_addr: &node_by_addr,
out: Vec::new(),
visited: HashSet::new(),
max_depth,
};
let s = &graph.stats;
w.out.push("naksheap object graph".to_string());
w.out.push(format!(
"objects: {} (allocated: {}, freed: {}, mmap: {}), clusters: {}, root-reachable: {}, edges: {} ({} confirmed), max depth: {}",
s.total_objects, s.allocated, s.freed, s.mmap, s.clusters, s.root_reachable, s.edges,
s.confirmed_edges, s.max_depth
));
if graph.nodes.is_empty() {
w.out.push("(no objects)".to_string());
return w.out.join("\n");
}
for (i, node) in pick_roots(graph, &node_by_addr).iter().enumerate() {
if i > 0 {
w.out.push(String::new());
}
if w.visited.contains(&node.addr) {
w.out.push(format!("0x{:x} (see above)", node.addr));
continue;
}
w.visited.insert(node.addr);
w.render_root(node);
}
let unreachable: Vec<&Node> = graph
.nodes
.iter()
.filter(|n| !n.reachable_from_root && !n.is_root)
.collect();
if !unreachable.is_empty() {
w.out.push(String::new());
let total = unreachable.len();
w.out.push(format!(
"unreachable ({}): {} not reachable from any root",
total,
if total == 1 { "object" } else { "objects" }
));
for n in unreachable.iter().take(MAX_UNREACHABLE_LISTED) {
w.out.push(format!(
" 0x{:x} {} [conf {:.2}]",
n.addr, n.ty.name, n.ty.confidence
));
}
if total > MAX_UNREACHABLE_LISTED {
w.out.push(format!(
" ... and {} more",
total - MAX_UNREACHABLE_LISTED
));
}
}
w.out.join("\n")
}
struct TreeWriter<'a> {
outgoing: &'a HashMap<u64, Vec<&'a GraphEdge>>,
node_by_addr: &'a HashMap<u64, &'a Node>,
out: Vec<String>,
visited: HashSet<u64>,
max_depth: usize,
}
impl TreeWriter<'_> {
fn render_root(&mut self, node: &Node) {
self.out.push(format!("0x{:x}", node.addr));
self.out.push(format!("└── {}", type_line(node)));
self.render_children(node, " ", 0);
}
fn render_node(&mut self, node: &Node, is_last: bool, prefix: &str, depth: usize) {
let conn = if is_last { "└── " } else { "├── " };
let spacing = if is_last { " " } else { "" };
self.out.push(format!("{prefix}{conn}0x{:x}", node.addr));
self.out.push(format!("{prefix}{spacing}└── {}", type_line(node)));
let child_indent = format!("{prefix}{spacing} ");
self.render_children(node, &child_indent, depth);
}
fn render_children(&mut self, node: &Node, indent: &str, depth: usize) {
let mut items: Vec<Item> = node.ty.fields.iter().map(Item::Field).collect();
let mut seen: HashSet<u64> = HashSet::new();
if let Some(edges) = self.outgoing.get(&node.addr) {
for e in edges {
if let Some(target) = self.node_by_addr.get(&e.to).copied() {
if seen.insert(target.addr) {
items.push(Item::Node(target));
}
}
}
}
for (i, item) in items.iter().enumerate() {
let last = i == items.len() - 1;
let conn = if last { "└── " } else { "├── " };
match item {
Item::Field(f) => {
self.out.push(format!("{indent}{conn}{}", field_body(f)));
}
Item::Node(n) => {
if self.visited.contains(&n.addr) {
self.out.push(format!("{indent}{conn}0x{:x} (see above)", n.addr));
} else if depth + 1 > self.max_depth {
self.out.push(format!("{indent}{conn}0x{:x} (depth limit)", n.addr));
} else {
self.visited.insert(n.addr);
self.render_node(n, last, indent, depth + 1);
}
}
}
}
}
}
/// Picks the render start points: every root node, or the single highest
/// inbound node when there are no roots. Deterministic (sorted by address).
fn pick_roots<'a>(graph: &'a ObjectGraph, node_by_addr: &HashMap<u64, &'a Node>) -> Vec<&'a Node> {
let mut addrs: Vec<u64> = graph
.nodes
.iter()
.filter(|n| n.is_root)
.map(|n| n.addr)
.collect();
addrs.sort_unstable();
let roots: Vec<&'a Node> = addrs
.iter()
.filter_map(|a| node_by_addr.get(a).copied())
.collect();
if !roots.is_empty() {
return roots;
}
let mut best: Option<&'a Node> = None;
for n in &graph.nodes {
let better = match best {
None => true,
Some(b) => n.inbound > b.inbound || (n.inbound == b.inbound && n.addr < b.addr),
};
if better {
best = Some(n);
}
}
best.into_iter().collect()
}
enum Item<'a> {
Field(&'a Field),
Node(&'a Node),
}
fn type_line(node: &Node) -> String {
let mut s = format!(
"{} [conf {:.2}, n={}]",
node.ty.name, node.ty.confidence, node.ty.members
);
if node.is_root {
s.push_str(" [root]");
}
if node.ty.label == ObjectLabel::FreedChunk {
s.push_str(" [freed]");
}
s
}
fn field_body(f: &Field) -> String {
let kind = kind_str(f.kind);
let hint = f.hint.as_deref().unwrap_or("");
let body = if hint.is_empty() {
kind.to_string()
} else if hint.starts_with(kind) {
hint.to_string()
} else {
format!("{kind} {hint}")
};
format!("+0x{:02x} {body}", f.offset)
}
fn kind_str(kind: FieldKind) -> &'static str {
match kind {
FieldKind::Pointer => "pointer",
FieldKind::Vtable => "vtable",
FieldKind::String => "string",
FieldKind::Integer => "integer",
FieldKind::Bytes => "bytes",
}
}

View file

@ -0,0 +1,59 @@
//! Graphviz DOT rendering of the object graph.
use std::collections::HashSet;
use naksheap_inference::{Node, ObjectGraph, ObjectLabel};
/// Renders `graph` as a Graphviz DOT digraph. Nodes are colored by state
/// (root green, unreachable orange, freed gray, allocated lightblue); edges
/// carry the pointer offset and are drawn thick when confirmed.
pub fn to_dot(graph: &ObjectGraph) -> String {
let mut out = String::new();
out.push_str("digraph naksheap {\n");
out.push_str(" graph [fontname=\"monospace\"];\n");
out.push_str(" node [fontname=\"monospace\" shape=\"box\"];\n");
out.push_str(" edge [fontname=\"monospace\"];\n");
let node_addrs: HashSet<u64> = graph.nodes.iter().map(|n| n.addr).collect();
for n in &graph.nodes {
let color = node_color(n);
let name = dot_escape(&n.ty.name);
out.push_str(&format!(
" \"0x{:x}\" [label=\"0x{:x}\\n{} [conf {:.2}]\" color=\"{}\" fillcolor=\"{}\" style=\"filled\"];\n",
n.addr, n.addr, name, n.ty.confidence, color, color
));
}
for e in &graph.edges {
if e.from == 0 || !node_addrs.contains(&e.from) || !node_addrs.contains(&e.to) {
continue;
}
let penwidth = if e.confirmed { 2 } else { 1 };
out.push_str(&format!(
" \"0x{:x}\" -> \"0x{:x}\" [label=\"0x{:x}\" penwidth={}];\n",
e.from, e.to, e.offset, penwidth
));
}
out.push_str("}\n");
out
}
fn node_color(n: &Node) -> &'static str {
if n.is_root {
"green"
} else if !n.reachable_from_root {
"orange"
} else if n.ty.label == ObjectLabel::FreedChunk {
"gray"
} else {
"lightblue"
}
}
fn dot_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}

View file

@ -0,0 +1,161 @@
//! HTML report rendering: the graph JSON is embedded in the file, while
//! cytoscape.js is loaded from a CDN at view time (network fetch required).
use std::collections::HashSet;
use naksheap_inference::{graph_to_json, Node, ObjectGraph, ObjectLabel};
use serde_json::{json, Value};
/// Renders `graph` as a single HTML file. The graph is embedded as a JSON blob
/// (with `</` escaped so it is safe inside a `<script>` tag) and drawn with
/// cytoscape.js loaded from a CDN. The HTML itself is not self-contained: it
/// needs a network fetch of `https://unpkg.com/cytoscape/...` to render. All
/// dump-derived data stays in the file / on the machine; the CDN request is the
/// only external fetch.
pub fn to_html(graph: &ObjectGraph) -> String {
let data = json!({
"elements": build_elements(graph),
"graph": graph_to_json(graph),
});
let blob = data.to_string().replace("</", "<\\/");
let template = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>naksheap object graph</title>
<!-- offline: data stays local; only the cytoscape.js CDN fetch is external -->
<script src="https://unpkg.com/cytoscape/dist/cytoscape.min.js"></script>
<style>
body { font-family: monospace; margin: 0; background: #fafafa; color: #222; }
h1 { font-size: 18px; margin: 0; padding: 12px 16px 4px; }
#stats { font-size: 13px; padding: 0 16px 8px; color: #444; }
#legend { font-size: 12px; padding: 0 16px 8px; }
#legend span { margin-right: 14px; }
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 4px; border: 1px solid #bbb; vertical-align: -1px; }
#cy { position: fixed; top: 92px; left: 0; right: 0; bottom: 0; }
</style>
</head>
<body>
<h1>naksheap object graph</h1>
<div id="stats">loading&hellip;</div>
<div id="legend">
<span><span class="swatch" style="background:#2e7d32"></span>root</span>
<span><span class="swatch" style="background:#1e88e5"></span>allocated</span>
<span><span class="swatch" style="background:#9e9e9e"></span>freed</span>
<span><span class="swatch" style="background:#ef6c00"></span>not root-reachable</span>
</div>
<div id="cy"></div>
<script>
const DATA = __DATA__;
(function () {
const s = DATA.graph.stats;
const el = document.getElementById('stats');
el.textContent = 'objects: ' + s.total_objects
+ ' | allocated: ' + s.allocated
+ ' | freed: ' + s.freed
+ ' | root-reachable: ' + s.root_reachable
+ ' | edges: ' + s.edges
+ ' (confirmed: ' + s.confirmed_edges + ')'
+ ' | max depth: ' + s.max_depth;
const cy = cytoscape({
container: document.getElementById('cy'),
elements: DATA.elements,
style: [
{ selector: 'node', style: {
'label': 'data(label)',
'background-color': 'data(color)',
'text-valign': 'center',
'text-halign': 'center',
'font-family': 'monospace',
'font-size': 10,
'width': 'data(w)',
'height': 34,
'border-width': 1,
'border-color': '#555'
} },
{ selector: 'edge', style: {
'label': 'data(label)',
'width': 'data(width)',
'font-family': 'monospace',
'font-size': 8,
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'arrow-scale': 0.8
} }
],
layout: { name: 'cose', animate: false, nodeRepulsion: 9000, idealEdgeLength: 110, padding: 30 }
});
})();
</script>
</body>
</html>
"#;
template.replace("__DATA__", &blob)
}
/// Builds the cytoscape `elements` array (nodes + object edges) from the graph.
fn build_elements(graph: &ObjectGraph) -> Vec<Value> {
let mut elements: Vec<Value> = Vec::new();
let node_addrs: HashSet<u64> = graph.nodes.iter().map(|n| n.addr).collect();
for n in &graph.nodes {
let id = format!("0x{:x}", n.addr);
let color = node_color(n);
let state = if n.is_root {
"root"
} else if !n.reachable_from_root {
"unreachable"
} else if n.ty.label == ObjectLabel::FreedChunk {
"freed"
} else {
"allocated"
};
elements.push(json!({
"data": {
"id": id,
"label": format!("0x{:x}\n{} [conf {:.2}]", n.addr, n.ty.name, n.ty.confidence),
"color": color,
"state": state,
"w": size_bounded(n.size),
}
}));
}
let mut edge_idx = 0usize;
for e in &graph.edges {
if e.from == 0 || !node_addrs.contains(&e.from) || !node_addrs.contains(&e.to) {
continue;
}
elements.push(json!({
"data": {
"id": format!("e{edge_idx}"),
"source": format!("0x{:x}", e.from),
"target": format!("0x{:x}", e.to),
"label": format!("0x{:x}", e.offset),
"width": if e.confirmed { 2.5 } else { 1.0 },
}
}));
edge_idx += 1;
}
elements
}
fn node_color(n: &Node) -> &'static str {
if n.is_root {
"#2e7d32"
} else if !n.reachable_from_root {
"#ef6c00"
} else if n.ty.label == ObjectLabel::FreedChunk {
"#9e9e9e"
} else {
"#1e88e5"
}
}
fn size_bounded(size: u64) -> u64 {
size.clamp(16, 256)
}

View file

@ -0,0 +1,132 @@
//! naksheap-viz
//!
//! Visualization of the naksheap object graph: a root-anchored ASCII tree,
//! Graphviz DOT, and an HTML/cytoscape report (graph JSON embedded, cytoscape.js
//! loaded from a CDN).
pub mod ascii;
pub mod dot;
pub mod html;
pub use ascii::ascii_tree;
pub use dot::to_dot;
pub use html::to_html;
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use naksheap_allocator_heuristics::carve;
use naksheap_core_parse::elf::parse_elf_bytes;
use naksheap_core_parse::MappedImage;
use naksheap_inference::{Node, ObjectGraph};
use naksheap_pointer_scan::{scan, ScanOptions};
use super::*;
/// Builds the fixture and runs the full pipeline, returning the object
/// graph plus the ground-truth fixture for address assertions.
fn fixture_graph() -> (ObjectGraph, naksheap_testkit::Fixture) {
let fixture = naksheap_testkit::CoreSpec::default()
.build()
.expect("fixture build");
let parsed = parse_elf_bytes(&fixture.bytes).expect("parse fixture core");
let image = MappedImage::from_bytes(
fixture.bytes.clone(),
parsed.map.clone(),
parsed.pointer_width,
);
let inventory = carve(&image);
let scan = scan(&image, &inventory, &parsed.threads, &ScanOptions::default());
let graph = naksheap_inference::build_graph(&image, &inventory, &scan);
(graph, fixture)
}
#[test]
fn ascii_tree_contains_fields_and_children() {
let (graph, fixture) = fixture_graph();
let tree = ascii_tree(&graph, 8);
assert!(tree.contains("naksheap object graph"), "tree: {tree}");
assert!(tree.contains("probable struct"), "tree: {tree}");
assert!(tree.contains("+0x00 pointer"), "tree: {tree}");
assert!(tree.contains("[root]"), "tree: {tree}");
assert!(tree.contains("(see above)"), "cycle dedup marker missing: {tree}");
let unreachable: Vec<&Node> = graph
.nodes
.iter()
.filter(|n| !n.reachable_from_root && !n.is_root)
.collect();
assert!(
!unreachable.is_empty(),
"fixture should contain objects unreachable from roots"
);
assert!(tree.contains("unreachable"), "unreachable section missing: {tree}");
for n in unreachable {
assert!(
tree.contains(&format!("0x{:x}", n.addr)),
"unreachable address 0x{:x} missing from tree:\n{tree}",
n.addr
);
}
let roots: Vec<u64> = graph
.nodes
.iter()
.filter(|n| n.is_root)
.map(|n| n.addr)
.collect();
assert!(!roots.is_empty(), "fixture should produce root nodes");
let mut reachable: HashSet<u64> = roots.iter().copied().collect();
loop {
let before = reachable.len();
for e in &graph.edges {
if reachable.contains(&e.from) && e.from != 0 {
reachable.insert(e.to);
}
}
if reachable.len() == before {
break;
}
}
for addr in reachable {
assert!(
tree.contains(&format!("0x{addr:x}")),
"reachable child address 0x{addr:x} missing from tree:\n{tree}"
);
}
assert!(tree.contains(&format!("0x{:x}", fixture.manifest.objects[1].addr)));
}
#[test]
fn dot_output_starts_with_digraph() {
let (graph, _fixture) = fixture_graph();
let dot = to_dot(&graph);
assert!(dot.starts_with("digraph"), "dot: {dot}");
assert!(dot.contains("->"), "dot: {dot}");
assert!(dot.contains("0x"), "dot: {dot}");
}
#[test]
fn html_contains_embedded_json() {
let (graph, fixture) = fixture_graph();
let html = to_html(&graph);
assert!(html.contains("cytoscape"), "html: {html}");
assert!(html.contains("stats"), "html: {html}");
assert!(
html.contains(&format!("0x{:x}", fixture.manifest.objects[0].addr)),
"html: {html}"
);
let data_line = html
.lines()
.find(|l| l.trim_start().starts_with("const DATA = "))
.expect("DATA line");
let data = data_line.trim_start().trim_start_matches("const DATA = ");
assert!(
!data.contains("</"),
"embedded JSON contains an unescaped </ (would close the script tag)"
);
}
}