use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Row, Table};
use ratatui::Frame;
use seer_core::TakeoverVerdict;
use crate::tui::action::LensData;
use crate::tui::theme::Theme;
use crate::tui::widgets::{or_dash, panel, row_style, scroll_to};
fn verdict_tone(v: TakeoverVerdict) -> &'static str {
match v {
TakeoverVerdict::Vulnerable => "fail",
TakeoverVerdict::Potential => "warn",
TakeoverVerdict::Safe => "ok",
}
}
fn verdict_label(v: TakeoverVerdict) -> &'static str {
match v {
TakeoverVerdict::Vulnerable => "VULNERABLE",
TakeoverVerdict::Potential => "potential",
TakeoverVerdict::Safe => "safe",
}
}
pub fn render(
f: &mut Frame,
area: Rect,
theme: &Theme,
data: &LensData,
focused: bool,
sel: usize,
) {
let LensData::Takeover(t) = data else { return };
let accent = if t.vulnerable > 0 {
theme.red
} else if t.potential > 0 {
theme.yellow
} else {
theme.green
};
let title = format!(
"Takeover · {} checked · {} vulnerable · {} potential",
t.hosts_checked, t.vulnerable, t.potential
);
let inner = panel::render(f, area, theme, &title, accent, focused);
if t.findings.is_empty() {
let msg = if t.hosts_checked == 0 {
"no hosts to scan".to_string()
} else {
format!("no takeover signals across {} host(s)", t.hosts_checked)
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
msg,
Style::default()
.fg(theme.green)
.add_modifier(Modifier::ITALIC),
))),
inner,
);
return;
}
let skipped_rows = u16::from(t.hosts_skipped > 0);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(skipped_rows)])
.split(inner);
let header = Row::new(["HOST", "VERDICT", "PROVIDER", "EVIDENCE"])
.style(Style::default().fg(theme.overlay0));
let rows = t.findings.iter().enumerate().map(|(i, finding)| {
let base = row_style(theme, focused && i == sel);
Row::new(vec![
Span::styled(finding.host.clone(), base),
Span::styled(
verdict_label(finding.verdict).to_string(),
base.fg(theme.tone(verdict_tone(finding.verdict))),
),
Span::styled(or_dash(finding.provider.as_deref()), base),
Span::styled(
or_dash(finding.evidence.as_ref().or(finding.probe_note.as_ref())),
base.fg(theme.subtext),
),
])
.style(base)
});
let table = Table::new(
rows,
[
Constraint::Percentage(32),
Constraint::Length(11),
Constraint::Percentage(20),
Constraint::Percentage(38),
],
)
.header(header)
.column_spacing(1);
let mut state = scroll_to(focused.then_some(sel));
f.render_stateful_widget(table, chunks[0], &mut state);
if t.hosts_skipped > 0 {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(
"{} host(s) exceeded the scan cap and were not examined",
t.hosts_skipped
),
Style::default().fg(theme.yellow),
))),
chunks[1],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::test_util::{render_buffer, render_lines};
use seer_core::{TakeoverFinding, TakeoverReport};
fn finding(host: &str, verdict: TakeoverVerdict) -> TakeoverFinding {
TakeoverFinding {
host: host.into(),
verdict,
provider: Some("GitHub Pages".into()),
cname: Some("x.github.io".into()),
addresses: vec![],
evidence: (verdict == TakeoverVerdict::Vulnerable)
.then(|| "There isn't a GitHub Pages site here.".to_string()),
http_status: Some(404),
probe_note: None,
}
}
fn report(findings: Vec<TakeoverFinding>) -> TakeoverReport {
let vulnerable = findings
.iter()
.filter(|f| f.verdict == TakeoverVerdict::Vulnerable)
.count();
let potential = findings
.iter()
.filter(|f| f.verdict == TakeoverVerdict::Potential)
.count();
TakeoverReport {
domain: "example.com".into(),
hosts_checked: 12,
hosts_skipped: 0,
vulnerable,
potential,
findings,
notes: vec![],
}
}
fn render_to_text(data: &LensData, w: u16, h: u16, focused: bool, sel: usize) -> String {
let theme = Theme::frappe();
render_lines(w, h, |f| render(f, f.area(), &theme, data, focused, sel))
}
#[test]
fn renders_vulnerable_row_with_evidence() {
let data = LensData::Takeover(Box::new(report(vec![finding(
"gone.example.com",
TakeoverVerdict::Vulnerable,
)])));
let s = render_to_text(&data, 110, 10, false, 0);
assert!(s.contains("gone.example.com"), "got: {s}");
assert!(s.contains("VULNERABLE"), "got: {s}");
assert!(s.contains("GitHub Pages site here"), "got: {s}");
}
#[test]
fn summary_counts_appear_in_the_title() {
let data = LensData::Takeover(Box::new(report(vec![
finding("a.example.com", TakeoverVerdict::Vulnerable),
finding("b.example.com", TakeoverVerdict::Potential),
])));
let s = render_to_text(&data, 110, 10, false, 0);
assert!(s.contains("12 checked"), "got: {s}");
assert!(s.contains("1 vulnerable"), "got: {s}");
assert!(s.contains("1 potential"), "got: {s}");
}
#[test]
fn clean_scan_reports_hosts_checked_not_an_empty_table() {
let data = LensData::Takeover(Box::new(report(vec![])));
let s = render_to_text(&data, 80, 8, false, 0);
assert!(
s.contains("no takeover signals across 12 host(s)"),
"got: {s}"
);
}
#[test]
fn skipped_hosts_are_surfaced() {
let mut r = report(vec![finding("a.example.com", TakeoverVerdict::Potential)]);
r.hosts_skipped = 37;
let s = render_to_text(&LensData::Takeover(Box::new(r)), 100, 10, false, 0);
assert!(s.contains("37 host(s) exceeded the scan cap"), "got: {s}");
}
#[test]
fn selecting_past_viewport_scrolls_row_into_view() {
let findings: Vec<TakeoverFinding> = (0..60)
.map(|i| finding(&format!("h{i}.example.com"), TakeoverVerdict::Potential))
.collect();
let data = LensData::Takeover(Box::new(report(findings)));
let s = render_to_text(&data, 100, 10, true, 59);
assert!(
s.contains("h59.example.com"),
"selecting the last row must scroll it into view: {s}"
);
}
#[test]
fn verdict_tones_and_labels() {
assert_eq!(verdict_tone(TakeoverVerdict::Vulnerable), "fail");
assert_eq!(verdict_tone(TakeoverVerdict::Potential), "warn");
assert_eq!(verdict_tone(TakeoverVerdict::Safe), "ok");
assert_eq!(verdict_label(TakeoverVerdict::Vulnerable), "VULNERABLE");
assert_eq!(verdict_label(TakeoverVerdict::Potential), "potential");
}
#[test]
fn wrong_payload_variant_renders_nothing() {
let theme = Theme::frappe();
let data = LensData::History(vec![]);
render_buffer(40, 6, |f| render(f, f.area(), &theme, &data, false, 0));
}
}