mirror of
https://github.com/vee1e/naksheap.git
synced 2026-09-01 10:18:37 +00:00
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:
commit
a48b163683
59 changed files with 10518 additions and 0 deletions
18
crates/naksheap-viz/Cargo.toml
Normal file
18
crates/naksheap-viz/Cargo.toml
Normal 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" }
|
||||
224
crates/naksheap-viz/src/ascii.rs
Normal file
224
crates/naksheap-viz/src/ascii.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
59
crates/naksheap-viz/src/dot.rs
Normal file
59
crates/naksheap-viz/src/dot.rs
Normal 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")
|
||||
}
|
||||
161
crates/naksheap-viz/src/html.rs
Normal file
161
crates/naksheap-viz/src/html.rs
Normal 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…</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)
|
||||
}
|
||||
132
crates/naksheap-viz/src/lib.rs
Normal file
132
crates/naksheap-viz/src/lib.rs
Normal 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue