use super::{
graph_horizontal_offset, graph_layout, graph_view_offset, App, Focus, LeftView, Line, RefView,
GRAPH_NODE_WIDTH,
};
use crate::listing::Annot;
use iced_x86::FlowControl;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as TLine, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::Frame;
pub(crate) fn accent() -> Color {
Color::Rgb(0xf0, 0xf0, 0xf0)
}
fn critical() -> Color {
Color::Rgb(0xff, 0xff, 0xff)
}
pub(crate) fn muted() -> Color {
Color::Rgb(0xc0, 0xc0, 0xc0)
}
pub(crate) fn faint() -> Color {
Color::Rgb(0x88, 0x88, 0x88)
}
pub(crate) fn mint() -> Color {
Color::Rgb(0xd8, 0xd8, 0xd8)
}
pub(crate) fn amber() -> Color {
Color::Rgb(0xe0, 0xe0, 0xe0)
}
pub(crate) fn canvas() -> Color {
Color::Rgb(0x00, 0x00, 0x00)
}
pub(crate) fn panel() -> Color {
Color::Rgb(0x12, 0x12, 0x12)
}
pub(crate) fn selected() -> Style {
Style::default()
.fg(Color::Rgb(0xff, 0xff, 0xff))
.bg(Color::Rgb(0x38, 0x38, 0x38))
.add_modifier(Modifier::BOLD)
}
pub(crate) fn pane(title: &str, focused: bool) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.style(Style::default().fg(muted()).bg(panel()))
.border_style(Style::default().fg(if focused { accent() } else { faint() }))
.title(Span::styled(
format!("{} {title} ", if focused { ">" } else { " " }),
Style::default()
.fg(if focused { canvas() } else { muted() })
.bg(if focused { accent() } else { panel() })
.add_modifier(if focused {
Modifier::BOLD
} else {
Modifier::empty()
}),
))
}
const FIXED_FUNCTION_COLUMNS: usize = 15;
const FIXED_SINK_COLUMNS: usize = 14;
pub fn loading(f: &mut Frame, title: &str, elapsed_seconds: u64) {
let title: String = title
.chars()
.map(|ch| if ch.is_control() { '.' } else { ch })
.collect();
let text = format!("KNIFE | {title}\n\nRecovering functions and preparing analysis views...\nElapsed: {elapsed_seconds}s | progress total unavailable\n\nq / Esc / Ctrl+C: quit");
f.render_widget(
Paragraph::new(text).style(Style::default().fg(muted()).bg(canvas())),
f.area(),
);
}
pub fn draw(f: &mut Frame, app: &App) {
if app.splash {
super::splash::draw(f, f.area(), app.frame, false);
return;
}
let area = f.area();
if area.width < 24 || area.height < 6 {
f.render_widget(
Paragraph::new("KNIFE\n\nterminal too small; resize the window")
.style(Style::default().fg(muted()).bg(canvas()))
.wrap(ratatui::widgets::Wrap { trim: true }),
area,
);
return;
}
f.render_widget(
Block::default().style(Style::default().fg(muted()).bg(canvas())),
f.area(),
);
let panes = app.panes(f.area());
header(f, panes.header, app);
if panes.functions.width > 0 {
functions(f, panes.functions, app);
}
if app.split {
let (left, right) = if app.active_is_left {
(app.active_view(), app.other_view())
} else {
(app.other_view(), app.active_view())
};
listing(f, panes.listing, app, &left);
listing(f, panes.listing_right, app, &right);
} else {
listing(f, panes.listing, app, &app.active_view());
if panes.references.width > 0 && panes.references.height > 0 {
xrefs(f, panes.references, app);
}
}
if panes.detail.height > 0 {
if app.left == LeftView::Types {
type_detail(f, panes.detail, app);
} else {
evidence(f, panes.detail, app);
}
}
footer(f, panes.footer, app);
if app.help {
help(f, f.area());
}
}
fn header(f: &mut Frame, area: Rect, app: &App) {
let named = app.an.functions.iter().filter(|x| x.named).count();
let high = app
.sinks
.iter()
.filter(|finding| finding.severity >= 3)
.count();
let line = TLine::from(vec![
Span::styled(
format!(" KNIFE {} ", app.title),
Style::default().fg(accent()).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
"{} {} │ {} functions {} named │ {} high-risk",
app.bin.format.label(),
app.bin.arch.label(),
app.an.functions.len(),
named,
high,
),
Style::default().fg(faint()),
),
]);
f.render_widget(Paragraph::new(line), area);
}
fn functions(f: &mut Frame, area: Rect, app: &App) {
if let Some(browser) = &app.browser {
catalog(f, area, app, browser);
return;
}
if app.left == LeftView::Sinks {
sinks(f, area, app);
return;
}
if app.left == LeftView::Driver {
driver_view(f, area, app);
return;
}
if app.left == LeftView::Types {
type_browser(f, area, app);
return;
}
let focused = app.focus == Focus::Functions;
let current = app.cur;
let name_w = usize::from(area.width.saturating_sub(3))
.saturating_sub(FIXED_FUNCTION_COLUMNS)
.clamp(8, 40);
let items: Vec<ListItem> = app
.order
.iter()
.map(|&i| {
let fun = &app.an.functions[i];
let here = current == Some(fun.addr);
let mut spans = Vec::new();
if here {
spans.push(Span::styled("·", Style::default().fg(amber())));
}
spans.push(Span::styled(
format!("{:>10x} ", fun.addr + app.an.display_base),
Style::default().fg(faint()),
));
spans.push(Span::styled(
truncate(&fun.name, name_w),
Style::default().fg(if fun.named { mint() } else { muted() }),
));
spans.push(Span::styled(
format!(" {:>3}", fun.incoming),
Style::default().fg(faint()),
));
ListItem::new(TLine::from(spans))
})
.collect();
let title = if app.filter.is_empty() {
format!("functions ({})", app.order.len())
} else {
format!("functions ({}) /{}", app.order.len(), app.filter)
};
let mut state = ListState::default();
if !app.order.is_empty() {
state.select(Some(app.sel));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn catalog(f: &mut Frame, area: Rect, app: &App, browser: &super::browser::Browser) {
let height = usize::from(area.height.saturating_sub(2))
.div_ceil(2)
.max(1);
let start = browser
.selection
.saturating_sub(height / 2)
.min(browser.visible.len().saturating_sub(height));
let items: Vec<ListItem> = browser
.visible
.iter()
.skip(start)
.take(height)
.map(|index| {
let row = &browser.rows[*index];
let address = row
.address
.map(|value| format!("VA {:x}", value.get()))
.unwrap_or_else(|| "VA unresolved".into());
let offset = row
.file_offset
.map(|value| format!(" OFF {:x}", value.get()))
.unwrap_or_default();
let label: String = row
.label
.chars()
.map(|ch| if ch.is_control() { '.' } else { ch })
.collect();
ListItem::new(vec![
TLine::from(Span::styled(
truncate(&label, usize::from(area.width.saturating_sub(3))),
Style::default().fg(mint()),
)),
TLine::from(Span::styled(
format!("{address}{offset} {}", row.detail),
Style::default().fg(muted()),
)),
])
})
.collect();
let title = format!(
"{} {} /{}",
browser.catalog.title(),
browser.visible.len(),
browser.filter
);
let mut state = ListState::default();
if !browser.visible.is_empty() {
state.select(Some(browser.selection - start));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, app.focus == Focus::Functions))
.highlight_style(selected())
.highlight_symbol(">"),
area,
&mut state,
);
}
fn sinks(f: &mut Frame, area: Rect, app: &App) {
let focused = app.focus == Focus::Functions;
let inner = usize::from(area.width.saturating_sub(3));
let rest = inner.saturating_sub(FIXED_SINK_COLUMNS);
let pattern_w = rest.saturating_sub(8).clamp(6, 14);
let name_w = rest.saturating_sub(pattern_w + 1).max(4);
let items: Vec<ListItem> = if app.sinks.is_empty() {
vec![ListItem::new(TLine::from(Span::styled(
" no sinks found",
Style::default().fg(faint()),
)))]
} else {
app.sinks
.iter()
.map(|s| {
let (mark, color) = match s.severity {
3 => ("H", critical()),
2 => ("M", amber()),
_ => ("L", muted()),
};
let where_ = s.func.as_deref().unwrap_or("-");
ListItem::new(TLine::from(vec![
Span::styled(
format!("[{mark}] "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", truncate(&s.pattern.replace('-', " "), pattern_w)),
Style::default().fg(color),
),
Span::styled(
format!("{:>9x} ", s.addr + app.an.display_base),
Style::default().fg(faint()),
),
Span::styled(truncate(where_, name_w), Style::default().fg(muted())),
]))
})
.collect()
};
let title = format!("attack surface ({})", app.sinks.len());
let mut state = ListState::default();
if !app.sinks.is_empty() {
state.select(Some(app.ssel.min(app.sinks.len() - 1)));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn driver_view(f: &mut Frame, area: Rect, app: &App) {
let is_driver = app.driver.as_ref().is_some_and(|d| d.is_driver);
let focused = app.focus == Focus::Functions;
let fg = |c: Color| Style::default().fg(c);
let rows = app.driver_rows();
let items: Vec<ListItem> = rows
.iter()
.map(|r| {
if r.section {
ListItem::new(TLine::from(Span::styled(
format!("{} {}", r.label, r.detail),
fg(amber()).add_modifier(Modifier::BOLD),
)))
} else {
let mut spans = Vec::new();
let mark = if r.accent { "!" } else { "·" };
spans.push(Span::styled(
mark,
fg(if r.accent { accent() } else { muted() }),
));
spans.push(Span::styled(
format!(" {:<22}", truncate(&r.label, 22)),
fg(if r.faint {
muted()
} else if r.accent {
accent()
} else {
mint()
}),
));
spans.push(Span::styled(format!(" {}", r.detail), fg(faint())));
ListItem::new(TLine::from(spans))
}
})
.collect();
let mut state = ListState::default();
if !rows.is_empty() {
state.select(Some(app.dsel.min(rows.len() - 1)));
}
let title = format!(
"driver{} {}{}",
if is_driver { "" } else { " (not native)" },
if app.dsrch.is_empty() {
"".to_string()
} else {
format!("/{} ", app.dsrch)
},
if app.dreach { "[reachable]" } else { "" }
);
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn type_browser(f: &mut Frame, area: Rect, app: &App) {
let rows = app.type_rows();
let focused = app.focus == Focus::Functions;
let items: Vec<ListItem> = rows
.iter()
.map(|row| {
if row.section {
ListItem::new(TLine::from(Span::styled(
format!(" {}", row.label),
Style::default().fg(amber()).add_modifier(Modifier::BOLD),
)))
} else {
let color = match row.kind {
"prototype" => mint(),
"layout" => accent(),
"binding" => amber(),
"variable" => mint(),
_ => muted(),
};
ListItem::new(TLine::from(vec![
Span::styled("· ", Style::default().fg(color)),
Span::styled(
format!("{:<20}", truncate(&row.label, 20)),
Style::default().fg(color),
),
Span::styled(truncate(&row.detail, 13), Style::default().fg(faint())),
]))
}
})
.collect();
let mut state = ListState::default();
if !rows.is_empty() {
state.select(Some(app.tsel.min(rows.len() - 1)));
}
let title = if app.tysrch.is_empty() {
format!(
"types ({})",
app.db.prototypes.len()
+ app.db.fields.len()
+ app.db.bindings.len()
+ app.db.variables.len()
)
} else {
format!("types /{}", app.tysrch)
};
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn listing(f: &mut Frame, area: Rect, app: &App, view: &super::ListingViewRef) {
if view.graph {
function_graph(f, area, app, view);
return;
}
if view.pseudo {
pseudo(f, area, app, view);
return;
}
let focused = view.focused;
let title = match view.cur {
Some(a) => format!(
"{} @ 0x{:x}{}",
app.an.label(a),
a + app.an.display_base,
position(view.cursor, view.lines.len())
),
None => "listing".into(),
};
let items: Vec<ListItem> = view
.lines
.iter()
.map(|l| match l {
Line::Label { text, .. } => ListItem::new(TLine::from(Span::styled(
format!(" {text}:"),
Style::default().fg(amber()),
))),
Line::Data { addr, text } => ListItem::new(TLine::from(vec![
Span::styled(
format!("{:012x} ", addr + app.an.display_base),
Style::default().fg(faint()),
),
Span::styled(text.clone(), Style::default().fg(muted())),
])),
Line::Insn {
addr,
mnemonic,
operands,
annot,
..
} => {
let mut spans = vec![
Span::styled(
format!("{:012x} ", addr + app.an.display_base),
Style::default().fg(faint()),
),
Span::styled(format!("{mnemonic:<7} "), Style::default().fg(accent())),
Span::styled(operands.clone(), Style::default().fg(muted())),
];
if let Some(a) = annot {
let (text, color) = match a {
Annot::Note(t) => (t.clone(), amber()),
Annot::Symbol(t) => (t.clone(), mint()),
Annot::Local(t) => (t.clone(), faint()),
Annot::Text(t) => (format!("\"{t}\""), amber()),
Annot::Hint(t) => (t.clone(), faint()),
};
spans.push(Span::styled(
format!(" ; {text}"),
Style::default().fg(color),
));
}
ListItem::new(TLine::from(spans))
}
})
.collect();
let mut state = ListState::default();
if !view.lines.is_empty() {
state.select(Some(view.cursor));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn function_graph(f: &mut Frame, area: Rect, app: &App, view: &super::ListingViewRef) {
let focused = view.focused;
let Some(function) = view.cur.and_then(|address| app.an.find_function(address)) else {
f.render_widget(
Paragraph::new(" Open a recovered function, then press f.")
.style(Style::default().fg(faint()))
.block(pane("function graph", focused)),
area,
);
return;
};
let index: std::collections::BTreeMap<u64, usize> = function
.blocks
.iter()
.enumerate()
.map(|(i, block)| (block.start, i))
.collect();
let title = format!(
"function graph · {} · {} blocks{} · arrows move · ↵ opens",
function.name,
function.blocks.len(),
position(view.cursor, function.blocks.len())
);
f.render_widget(pane(&title, focused), area);
let inner = Rect::new(
area.x.saturating_add(1),
area.y.saturating_add(1),
area.width.saturating_sub(2),
area.height.saturating_sub(2),
);
if inner.is_empty() {
return;
}
let inspector_height = if inner.height >= 9 { 6 } else { 0 };
let map = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(inspector_height),
);
let inspector = Rect::new(
inner.x,
map.y.saturating_add(map.height),
inner.width,
inspector_height,
);
let layout = graph_layout(function, map.width);
let offset = graph_view_offset(&layout, view.cursor, map.height);
let horizontal = graph_horizontal_offset(&layout, view.cursor, map.width);
for (source_index, block) in function.blocks.iter().enumerate() {
let Some(source) = layout.nodes.iter().find(|node| node.index == source_index) else {
continue;
};
let conditional = block.insns.last().is_some_and(|instruction| {
instruction.flow == FlowControl::ConditionalBranch && block.succ.len() >= 2
});
for successor in &block.succ {
let Some(&target_index) = index.get(successor) else {
continue;
};
let Some(target) = layout.nodes.iter().find(|node| node.index == target_index) else {
continue;
};
let color = if conditional { amber() } else { accent() };
if target.y <= source.y {
let y = source.y.saturating_sub(offset);
if y < map.height {
let x = source.x.saturating_add(GRAPH_NODE_WIDTH);
if x >= horizontal && x - horizontal < map.width {
let visible_x = x - horizontal;
f.buffer_mut().set_stringn(
map.x.saturating_add(visible_x),
map.y.saturating_add(y),
format!("↑B{target_index:02}"),
map.width.saturating_sub(visible_x) as usize,
Style::default().fg(amber()),
);
}
}
continue;
}
let sx = source.x.saturating_add(GRAPH_NODE_WIDTH / 2);
let tx = target.x.saturating_add(GRAPH_NODE_WIDTH / 2);
let bend = target.y.saturating_sub(1);
for logical_y in source.y.saturating_add(1)..=bend {
if logical_y < offset || logical_y - offset >= map.height {
continue;
}
let symbol = if logical_y == bend { "─" } else { "│" };
if sx >= horizontal && sx - horizontal < map.width {
f.buffer_mut().set_string(
map.x.saturating_add(sx - horizontal),
map.y.saturating_add(logical_y - offset),
symbol,
Style::default().fg(color),
);
}
}
if bend >= offset && bend - offset < map.height {
let y = map.y.saturating_add(bend - offset);
for x in sx.min(tx)..=sx.max(tx) {
if x >= horizontal && x - horizontal < map.width {
f.buffer_mut().set_string(
map.x.saturating_add(x - horizontal),
y,
"─",
Style::default().fg(color),
);
}
}
if tx >= horizontal && tx - horizontal < map.width {
f.buffer_mut().set_string(
map.x.saturating_add(tx - horizontal),
y,
"▼",
Style::default().fg(color).add_modifier(Modifier::BOLD),
);
}
}
}
}
for node in &layout.nodes {
if node.y < offset || node.y - offset >= map.height {
continue;
}
let block = &function.blocks[node.index];
let terminal = block.succ.is_empty();
let is_selected = node.index == view.cursor;
let text = if is_selected {
format!("▶B{:02}◀", node.index)
} else {
format!("[B{:02}]", node.index)
};
let color = if is_selected {
Color::Black
} else if terminal {
critical()
} else if node.index == 0 {
mint()
} else {
accent()
};
let style = Style::default()
.fg(color)
.bg(if is_selected { accent() } else { panel() })
.add_modifier(Modifier::BOLD);
if node.x < horizontal || node.x >= horizontal.saturating_add(map.width) {
continue;
}
let visible_x = node.x.saturating_sub(horizontal);
f.buffer_mut().set_stringn(
map.x.saturating_add(visible_x),
map.y.saturating_add(node.y - offset),
text,
GRAPH_NODE_WIDTH.min(map.width.saturating_sub(visible_x)) as usize,
style,
);
}
if inspector.height > 0 {
let selected_index = view.cursor.min(function.blocks.len().saturating_sub(1));
if let Some(block) = function.blocks.get(selected_index) {
let calls = block
.insns
.iter()
.filter(|instruction| {
matches!(
instruction.flow,
FlowControl::Call | FlowControl::IndirectCall
)
})
.count();
let mut lines = vec![TLine::from(vec![
Span::styled(
format!(" B{selected_index:02} "),
Style::default()
.fg(Color::Black)
.bg(accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
" 0x{:x} · {} insns{} ",
block.start + app.an.display_base,
block.insns.len(),
if calls > 0 {
format!(" · {calls} calls")
} else {
String::new()
}
),
Style::default().fg(faint()),
),
])];
lines.push(graph_edges(block, &index));
for instruction in block
.insns
.iter()
.take(inspector.height.saturating_sub(2) as usize)
{
lines.push(TLine::from(vec![
Span::styled(
format!(" {:x} ", instruction.addr + app.an.display_base),
Style::default().fg(faint()),
),
Span::styled(
truncate(
&instruction.text(app.an.bits, app.an.arch),
inner.width.saturating_sub(16).max(8) as usize,
),
Style::default().fg(muted()),
),
]));
}
f.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(faint())),
),
inspector,
);
}
}
}
fn graph_edges(
block: &crate::analysis::engine::BasicBlock,
index: &std::collections::BTreeMap<u64, usize>,
) -> TLine<'static> {
let mut spans = vec![Span::styled("╰─ ", Style::default().fg(accent()))];
let last = block.insns.last();
if last.is_some_and(|instruction| instruction.flow == FlowControl::Return) {
spans.push(Span::styled(
"RETURN",
Style::default().fg(critical()).add_modifier(Modifier::BOLD),
));
return TLine::from(spans);
}
if block.succ.is_empty() {
spans.push(Span::styled("TERMINAL", Style::default().fg(critical())));
return TLine::from(spans);
}
let conditional = last.is_some_and(|instruction| {
instruction.flow == FlowControl::ConditionalBranch && block.succ.len() >= 2
});
for (position, successor) in block.succ.iter().enumerate() {
if position > 0 {
spans.push(Span::styled(" ", Style::default().fg(faint())));
}
let label = if conditional {
if last.and_then(|instruction| instruction.target) == Some(*successor) {
"TRUE"
} else {
"FALSE"
}
} else {
"FLOW"
};
let target = index
.get(successor)
.map(|i| format!("B{i:02}"))
.unwrap_or_else(|| format!("0x{successor:x}"));
spans.push(Span::styled(
format!("{label} → {target}"),
Style::default()
.fg(if conditional { amber() } else { accent() })
.add_modifier(Modifier::BOLD),
));
}
TLine::from(spans)
}
fn pseudo_spans(text: &str) -> Vec<Span<'static>> {
const KW: &[&str] = &[
"if", "else", "while", "switch", "case", "break", "continue", "goto", "return", "do", "for",
];
const TYPES: &[&str] = &[
"void",
"bool",
"int",
"size_t",
"ssize_t",
"uintptr_t",
"uint8_t",
"wchar_t",
"char",
];
let mut spans = Vec::new();
let mut buf = String::new();
let mut buf_word = false;
let mut after_arrow = false;
let flush = |buf: &mut String, word: bool, member: bool, spans: &mut Vec<Span<'static>>| {
if buf.is_empty() {
return;
}
let color = if word {
if KW.contains(&buf.as_str()) {
accent()
} else if member || TYPES.contains(&buf.as_str()) || buf.starts_with("field_") {
mint()
} else {
muted()
}
} else {
muted()
};
spans.push(Span::styled(
std::mem::take(buf),
Style::default().fg(color),
));
};
for ch in text.chars() {
let word = ch.is_alphanumeric() || ch == '_';
if word != buf_word {
if buf_word {
flush(&mut buf, true, after_arrow, &mut spans);
after_arrow = false;
} else {
after_arrow = buf.ends_with("->");
flush(&mut buf, false, false, &mut spans);
}
buf_word = word;
}
buf.push(ch);
}
flush(&mut buf, buf_word, buf_word && after_arrow, &mut spans);
spans
}
fn pseudo(f: &mut Frame, area: Rect, app: &App, view: &super::ListingViewRef) {
let focused = view.focused;
let title = match view.cur {
Some(a) => format!(
"pseudocode · {} @ 0x{:x}{}",
app.an.label(a),
a + app.an.display_base,
position(view.cursor, view.pseudo_lines.len())
),
None => "pseudocode".into(),
};
let mut items: Vec<ListItem> = view
.pseudo_lines
.iter()
.map(|l| {
if l.label {
let color = if l.text.trim_end().ends_with(':') {
amber()
} else {
accent()
};
ListItem::new(TLine::from(Span::styled(
l.text.clone(),
Style::default().fg(color),
)))
} else {
ListItem::new(TLine::from(pseudo_spans(&l.text)))
}
})
.collect();
if items.is_empty() {
items.push(ListItem::new(TLine::from(Span::styled(
" (no pseudocode for this view)",
Style::default().fg(faint()),
))));
}
let mut state = ListState::default();
if !view.pseudo_lines.is_empty() {
state.select(Some(view.cursor.min(view.pseudo_lines.len() - 1)));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn type_detail(f: &mut Frame, area: Rect, app: &App) {
let rows = app.type_rows();
let Some(row) = rows.get(app.tsel) else {
return;
};
let address = row
.addr
.map(|address| format!("0x{:x}", address + app.an.display_base))
.unwrap_or_else(|| "layout fact".into());
let color = match row.kind {
"prototype" => mint(),
"layout" => accent(),
"binding" => amber(),
"variable" => mint(),
_ => muted(),
};
let lines = vec![
TLine::from(vec![
Span::styled(" kind ", Style::default().fg(faint())),
Span::styled(
row.kind.to_uppercase(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(" owner ", Style::default().fg(faint())),
Span::styled(row.label.clone(), Style::default().fg(mint())),
Span::styled(" at ", Style::default().fg(faint())),
Span::styled(address, Style::default().fg(muted())),
]),
TLine::from(vec![
Span::styled(" fact ", Style::default().fg(faint())),
Span::styled(row.detail.clone(), Style::default().fg(color)),
]),
TLine::from(Span::styled(
if row.addr.is_some() {
" enter opens the owning function · p/l/t/e edits in pseudocode"
} else {
" structure fields are reusable across scoped pointer bindings"
},
Style::default().fg(faint()),
)),
];
f.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(pane("type fact", false).border_style(Style::default().fg(color))),
area,
);
}
fn evidence(f: &mut Frame, area: Rect, app: &App) {
let Some(finding) = app.sinks.get(app.ssel) else {
return;
};
let (level, color) = match finding.severity {
3 => ("HIGH", critical()),
2 => ("MEDIUM", amber()),
_ => ("LOW", muted()),
};
let reach = if finding.reachable {
"REACHABLE"
} else {
"UNPROVEN"
};
let function = finding.func.as_deref().unwrap_or("unknown function");
let lines = vec![
TLine::from(vec![
Span::styled(" pattern ", Style::default().fg(faint())),
Span::styled(
finding.pattern,
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(" sink ", Style::default().fg(faint())),
Span::styled(
format!("{} @ 0x{:x}", finding.api, finding.addr),
Style::default().fg(mint()),
),
]),
TLine::from(vec![
Span::styled(" signal ", Style::default().fg(faint())),
Span::styled(
evidence_chain(&finding.detail, &finding.api),
Style::default().fg(amber()),
),
Span::styled(" in ", Style::default().fg(faint())),
Span::styled(function, Style::default().fg(muted())),
]),
TLine::from(vec![
Span::styled(" why ", Style::default().fg(faint())),
Span::styled(finding.detail.clone(), Style::default().fg(muted())),
]),
];
let title = format!("evidence · {level} · {reach}");
let block = pane(&title, false).border_style(Style::default().fg(color));
f.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: true }).block(block),
area,
);
}
fn evidence_chain(detail: &str, api: &str) -> String {
let source = if detail.contains("external-input API") {
"EXTERNAL INPUT"
} else if detail.contains("function argument") {
"ARGUMENT"
} else if detail.contains("some incoming paths") {
"CFG MERGE"
} else if detail.contains("subtraction") {
"SUBTRACTION"
} else if detail.contains("multiplication") {
"MULTIPLICATION"
} else if detail.contains("stack buffer") {
"STACK BUFFER"
} else {
"RUNTIME VALUE"
};
format!("{source} → DATA FLOW → {}", api.to_uppercase())
}
fn xrefs(f: &mut Frame, area: Rect, app: &App) {
let rows = app.xref_rows();
let focused = app.focus == Focus::Xrefs;
let title = match app.refview {
RefView::To => format!(
"xrefs to 0x{:x} ({})",
app.xref_at() + app.an.display_base,
rows.len()
),
RefView::Callers => {
let name = app.cur.map(|a| app.an.label(a)).unwrap_or_default();
format!("callers of {name} ({})", rows.len())
}
RefView::From => {
let name = app.cur.map(|a| app.an.label(a)).unwrap_or_default();
format!("calls from {name} ({})", rows.len())
}
};
let empty = if app.refview == RefView::To {
" no recovered references; indirect reachability unknown"
} else {
" no recovered calls; unresolved indirect calls may exist"
};
let items: Vec<ListItem> = if rows.is_empty() {
vec![ListItem::new(TLine::from(Span::styled(
empty,
Style::default().fg(faint()),
)))]
} else {
rows.iter()
.map(|row| {
ListItem::new(TLine::from(vec![
Span::styled(
format!(" {:>12x} ", row.site + app.an.display_base),
Style::default().fg(faint()),
),
Span::styled(
format!("{:<7}", row.kind),
Style::default().fg(match row.kind {
"call" => mint(),
"data" => muted(),
_ => amber(),
}),
),
Span::styled(row.label.clone(), Style::default().fg(muted())),
]))
})
.collect()
};
let mut state = ListState::default();
if !rows.is_empty() {
state.select(Some(app.xsel.min(rows.len() - 1)));
}
f.render_stateful_widget(
List::new(items)
.block(pane(&title, focused))
.highlight_style(selected())
.highlight_symbol("▸"),
area,
&mut state,
);
}
fn footer(f: &mut Frame, area: Rect, app: &App) {
if let Some(p) = &app.prompt {
let line = TLine::from(vec![
Span::styled(
if p.ask == super::Ask::Command {
" : ".into()
} else {
format!(" {}: ", p.ask.label())
},
Style::default().fg(accent()).add_modifier(Modifier::BOLD),
),
Span::raw(p.input.clone()),
Span::styled("█", Style::default().fg(accent())),
Span::styled(format!(" {}", app.status), Style::default().fg(muted())),
]);
f.render_widget(Paragraph::new(line), area);
return;
}
if !app.status.is_empty() {
f.render_widget(
Paragraph::new(TLine::from(Span::styled(
format!(" {}", app.status),
Style::default().fg(amber()),
))),
area,
);
return;
}
f.render_widget(
Paragraph::new(TLine::from(Span::styled(
footer_hints(app.focus, area.width),
Style::default().fg(muted()),
))),
area,
);
}
fn footer_hints(focus: Focus, width: u16) -> String {
let name = match focus {
Focus::Functions => "Browser",
Focus::Listing => "Listing",
Focus::Xrefs => "References",
};
let mut hints = format!(" {name} | : commands | ? help");
let find = match focus {
Focus::Listing => " | / search",
Focus::Functions => " | / filter",
Focus::Xrefs => "",
};
let enter = match focus {
Focus::Functions => " | Enter open",
Focus::Listing => " | Enter follow",
Focus::Xrefs => " | Enter jump",
};
for hint in [
" | Tab/S-Tab panes",
enter,
" | Backspace back",
" | g goto",
find,
" | q quit",
] {
if hints.len() + hint.len() <= usize::from(width) {
hints.push_str(hint);
}
}
hints
}
fn help(f: &mut Frame, area: Rect) {
let text = vec![
"",
" ↑ ↓ / j k move Tab/Shift+Tab next/previous pane",
" pgup pgdn page home end first / last",
" mouse wheel scrolls, click picks a pane and an entry",
" ↵ open, follow the call under the cursor, or jump to a",
" reference; a string operand opens its bytes as a dump",
" Backspace back to where you followed from",
" Alt+Left/Right back/forward, restoring view and cursor",
" :next/previous adjacent function in static-address order (no wrapping)",
" :split/:only two listing columns; :compare F pins a function there",
" :history browse/filter saved past and forward view locations",
" :focus PANE functions, listing, references (reveals closed pane)",
" :close close focused side pane; :widen/:narrow adjust width",
" : command mode; Tab completes, Up/Down recalls commands",
" functions, function NAME, goto VA, cfg, disasm, decompile",
" xrefs, callers, callees, types, rename NAME, comment TEXT, help",
" imports, exports, strings, sections; / filters catalog",
" b toggle local bookmark; :bookmarks lists saved locations",
"",
" / filter the function list, or search the code when the",
" listing is focused (/↵ repeats, jumping to the next hit)",
" s cycle functions, sinks, driver, and analyst types;",
" in types: I import, R replace, E export; ↵ opens owner",
" x toggle the reference pane between callers (xrefs to the",
" cursor) and callees (the calls the function makes)",
" g go to address/symbol; off:0x.. offset, va:0x.. VA",
" r re-analyse",
" :reload re-read the target from disk; desk layout survives",
" d toggle decompiled pseudocode for the current function",
" f toggle the spatial control-flow graph; arrows move by",
" layer/lane, click selects, ↵ opens the block in asm",
" (↑ marks a loop or cross edge to an earlier layer)",
" t bind/clear the selected pseudocode field base's type",
" e / l edit the selected field / rename the line's variable",
" p set/clear the current function prototype as",
" RETURN (PARAM, PARAM), e.g. bool (CONTEXT *, size_t)",
" P stage raw bytes at the selected assembly instruction;",
" submit an empty value to restore its entire patch run",
" n name what is under the cursor (empty clears it)",
" c note what is under the cursor (empty clears it)",
"",
" Names, notes, patches, variables, types, fields, and prototypes save",
" immediately; staged patches affect analysis but never modify the input;",
" they are already on disk when you quit.",
"",
" any key to dismiss",
];
let w = 74.min(area.width.saturating_sub(4));
let h = (text.len() as u16 + 2).min(area.height);
let box_area = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
};
f.render_widget(Clear, box_area);
f.render_widget(
Paragraph::new(
text.into_iter()
.map(|l| TLine::from(Span::styled(l, Style::default().fg(muted()))))
.collect::<Vec<_>>(),
)
.block(pane("keys", true)),
box_area,
);
}
fn position(cursor: usize, total: usize) -> String {
if total == 0 {
String::new()
} else {
format!(" · {}/{}", cursor + 1, total)
}
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
format!("{s:<max$}")
} else if max == 0 {
String::new()
} else {
let t: String = s.chars().take(max - 1).collect();
format!("{t}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monochrome_panes_have_square_borders_and_explicit_focus() {
use ratatui::{buffer::Buffer, widgets::Widget};
let area = Rect::new(0, 0, 24, 4);
let mut active = Buffer::empty(area);
pane("listing", true).render(area, &mut active);
assert_eq!(active[(0, 0)].symbol(), "┌");
assert_eq!(active[(1, 0)].symbol(), ">");
assert_eq!(active[(1, 0)].fg, canvas());
assert_eq!(active[(1, 0)].bg, accent());
assert_eq!(
active[(1, 1)].fg,
muted(),
"text must not inherit a light terminal's dark foreground"
);
let mut inactive = Buffer::empty(area);
pane("listing", false).render(area, &mut inactive);
assert_ne!(inactive[(1, 0)].bg, active[(1, 0)].bg);
for color in [
accent(),
critical(),
muted(),
faint(),
mint(),
amber(),
canvas(),
panel(),
] {
let Color::Rgb(r, g, b) = color else {
panic!("expected an explicit palette color")
};
assert_eq!(r, g);
assert_eq!(g, b);
}
}
#[test]
fn compact_footer_keeps_commands_and_help_visible() {
for focus in [Focus::Functions, Focus::Listing, Focus::Xrefs] {
for width in [40, 60, 80, 110, 160] {
let hints = footer_hints(focus, width);
assert!(hints.len() <= usize::from(width));
assert!(hints.contains(": commands"));
assert!(hints.contains("? help"));
}
}
assert!(footer_hints(Focus::Functions, 160).contains("/ filter"));
assert!(!footer_hints(Focus::Xrefs, 160).contains("/ search"));
}
#[test]
fn recovered_pseudocode_types_have_the_fact_color() {
let spans = pseudo_spans(
"wchar_t wide; uint8_t raw; uintptr_t unknown; ptr->field_18; ptr->length;",
);
for ty in ["wchar_t", "uint8_t", "uintptr_t", "field_18", "length"] {
let span = spans
.iter()
.find(|span| span.content.as_ref() == ty)
.unwrap_or_else(|| panic!("missing type token {ty}"));
assert_eq!(
span.style.fg,
Some(mint()),
"{ty} should use the recovered-fact color"
);
}
}
}