use ratatui::layout::Constraint;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Row;
use seer_core::output::sanitize_line;
use seer_core::HeaderVerdict;
use crate::tui::action::LensData;
use crate::tui::theme::Theme;
use crate::tui::widgets::inspector::Field;
use crate::tui::widgets::page::{Block, Detail, Page, TableBlock};
use crate::tui::widgets::{dot, gauge};
const GAUGE: u16 = 30;
fn verdict_tone(v: HeaderVerdict) -> &'static str {
match v {
HeaderVerdict::Strict | HeaderVerdict::Present => "ok",
HeaderVerdict::Moderate | HeaderVerdict::Weak | HeaderVerdict::Unknown => "warn",
HeaderVerdict::Absent => "fail",
}
}
fn grade_tone(grade: &str) -> &'static str {
match grade {
"A+" | "A" => "ok",
"B" | "C" => "warn",
_ => "fail",
}
}
pub fn page(theme: &Theme, data: &LensData, focused: bool, sel: usize) -> Option<Page> {
let LensData::Headers(h) = data else {
return None;
};
let label = format!("{} ({}/100)", h.grade, h.score);
let summary = vec![
gauge::line(
theme,
f64::from(h.score) / 100.0,
GAUGE,
theme.tone(grade_tone(&h.grade)),
Some(&label),
),
Line::from(Span::styled(
format!("{} [HTTP {}]", sanitize_line(&h.url), h.status),
Style::default().fg(theme.subtext),
)),
];
let names: Vec<String> = h.headers.iter().map(|f| sanitize_line(&f.header)).collect();
let name_w = names
.iter()
.map(|n| Span::raw(n.as_str()).width())
.max()
.unwrap_or(0);
let rows = h
.headers
.iter()
.zip(names)
.map(|(finding, name)| {
Row::new(vec![
Span::styled(name, Style::default().fg(theme.mauve)),
Span::styled(
finding.verdict.as_str(),
Style::default().fg(theme.tone(verdict_tone(finding.verdict))),
),
])
})
.collect();
let detail = focused
.then(|| h.headers.get(sel))
.flatten()
.map_or(Detail::None, |finding| {
Detail::Fields(vec![
Field::new("header", finding.header.clone()),
Field::new("verdict", finding.verdict.as_str()).tone(verdict_tone(finding.verdict)),
Field::new("value", finding.value.clone().unwrap_or_default()),
Field::new("note", finding.note.clone().unwrap_or_default()),
])
});
let width = u16::try_from(name_w.max("HEADER".len()) + 1).unwrap_or(u16::MAX);
let table = TableBlock::new(
vec!["HEADER", "VERDICT"],
vec![Constraint::Length(width), Constraint::Min(0)],
rows,
)
.selected(focused.then_some(sel))
.detail(detail);
let cookies = (!h.cookies.is_empty()).then(|| {
let flawed = h.cookies.iter().filter(|c| !c.issues.is_empty()).count();
let tone = if flawed == 0 { "ok" } else { "warn" };
dot::line(
theme,
tone,
format!("{} cookie(s), {} with issues", h.cookies.len(), flawed),
)
});
let advisories = if h.notes.is_empty() {
Block::Lines(vec![Line::from(Span::styled(
"no advisories — every graded header is set",
Style::default()
.fg(theme.green)
.add_modifier(Modifier::ITALIC),
))])
} else {
Block::Scroll {
lines: h
.notes
.iter()
.map(|n| dot::line(theme, "warn", n.clone()))
.collect(),
offset: 0,
unit: "advisories",
}
};
Some(
Page::new(
format!("HTTP Headers · {} · {}/100", h.grade, h.score),
theme.mauve,
)
.push(Block::Lines(summary))
.push(Block::Gap)
.push(Block::Table(table))
.push(Block::Gap)
.push_if(cookies.is_some(), || {
Block::Lines(cookies.into_iter().collect())
})
.push(advisories),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::widgets::page;
fn render(
f: &mut ratatui::Frame,
area: ratatui::layout::Rect,
theme: &Theme,
data: &LensData,
focused: bool,
sel: usize,
) {
if let Some(p) = page(theme, data, focused, sel) {
page::draw(f, area, theme, p, focused);
}
}
use crate::tui::test_util::{render_buffer, render_lines};
use seer_core::{CookieFinding, Disclosure, HeaderFinding, HeaderReport};
fn finding(header: &str, verdict: HeaderVerdict) -> HeaderFinding {
HeaderFinding {
header: header.into(),
present: verdict != HeaderVerdict::Absent,
value: None,
verdict,
note: None,
}
}
fn report() -> HeaderReport {
HeaderReport {
domain: "example.com".into(),
url: "https://example.com/".into(),
status: 200,
redirects: 0,
grade: "C".into(),
score: 65,
headers: vec![
finding("strict-transport-security", HeaderVerdict::Strict),
finding("content-security-policy", HeaderVerdict::Absent),
],
cookies: vec![CookieFinding {
name: "session".into(),
secure: false,
http_only: true,
same_site: None,
verdict: HeaderVerdict::Weak,
issues: vec!["missing Secure".into()],
}],
disclosures: vec![Disclosure {
header: "server".into(),
value: "nginx/1.25".into(),
versioned: true,
}],
notes: vec!["content-security-policy: Add a policy.".into()],
}
}
fn render_to_text(data: &LensData, width: u16, height: u16) -> String {
let theme = Theme::frappe();
render_lines(width, height, |f| {
render(f, f.area(), &theme, data, false, 0);
})
}
#[test]
fn selected_verdict_stays_visible_on_a_short_pane() {
let theme = Theme::frappe();
let mut r = report();
r.headers = (0..9)
.map(|i| finding(&format!("x-header-{i}"), HeaderVerdict::Present))
.collect();
r.headers[8].value = Some("a ".repeat(300));
let data = LensData::Headers(Box::new(r));
let s = render_lines(60, 18, |f| render(f, f.area(), &theme, &data, true, 8));
assert!(s.contains("x-header-8 "), "selected row must show: {s}");
assert!(s.contains("more lines"), "the detail is cut short: {s}");
}
#[test]
fn header_names_are_sanitized_and_sized_as_drawn() {
let mut r = report();
r.headers = vec![finding("x-\u{1b}[2Jframe", HeaderVerdict::Present)];
let s = render_to_text(&LensData::Headers(Box::new(r)), 80, 16);
assert!(!s.contains('\u{1b}'), "{s}");
assert!(s.contains("x-frame present"), "{s}");
}
#[test]
fn focused_header_shows_its_value_and_note() {
let theme = Theme::frappe();
let mut r = report();
r.headers[0].value = Some("max-age=63072000; includeSubDomains; preload".into());
r.headers[0].note = Some("Strict: long max-age with preload.".into());
let data = LensData::Headers(Box::new(r));
let s = render_lines(80, 24, |f| render(f, f.area(), &theme, &data, true, 0));
assert!(s.contains("includeSubDomains; preload"), "{s}");
assert!(s.contains("long max-age"), "{s}");
let nav = render_lines(80, 24, |f| render(f, f.area(), &theme, &data, false, 0));
assert!(!nav.contains("includeSubDomains"), "{nav}");
}
#[test]
fn renders_grade_and_score() {
let s = render_to_text(&LensData::Headers(Box::new(report())), 80, 16);
assert!(s.contains("65"), "score must be visible: {s}");
assert!(s.contains('C'), "grade must be visible: {s}");
}
fn verdict_on_row(s: &str, header: &str) -> String {
let row = s
.lines()
.find(|l| l.contains(&format!("{header} ")))
.unwrap_or_else(|| panic!("no verdict row for {header}: {s}"));
row.trim_end()
.trim_end_matches('│')
.split_whitespace()
.last()
.unwrap_or_default()
.to_string()
}
#[test]
fn renders_each_header_with_its_verdict() {
let s = render_to_text(&LensData::Headers(Box::new(report())), 80, 16);
assert_eq!(verdict_on_row(&s, "strict-transport-security"), "strict");
assert_eq!(verdict_on_row(&s, "content-security-policy"), "absent");
}
#[test]
fn renders_cookie_summary_and_advisories() {
let s = render_to_text(&LensData::Headers(Box::new(report())), 80, 16);
assert!(s.contains("1 cookie(s), 1 with issues"), "got: {s}");
assert!(s.contains("Add a policy"), "got: {s}");
}
#[test]
fn clean_report_says_so_rather_than_leaving_a_blank_band() {
let mut r = report();
r.notes.clear();
r.grade = "A+".into();
r.score = 100;
let s = render_to_text(&LensData::Headers(Box::new(r)), 80, 16);
assert!(s.contains("no advisories"), "got: {s}");
}
#[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));
}
#[test]
fn verdict_tones_follow_severity() {
assert_eq!(verdict_tone(HeaderVerdict::Strict), "ok");
assert_eq!(verdict_tone(HeaderVerdict::Present), "ok");
assert_eq!(verdict_tone(HeaderVerdict::Moderate), "warn");
assert_eq!(verdict_tone(HeaderVerdict::Weak), "warn");
assert_eq!(verdict_tone(HeaderVerdict::Absent), "fail");
}
#[test]
fn grade_tones_follow_bands() {
assert_eq!(grade_tone("A+"), "ok");
assert_eq!(grade_tone("A"), "ok");
assert_eq!(grade_tone("B"), "warn");
assert_eq!(grade_tone("C"), "warn");
assert_eq!(grade_tone("D"), "fail");
assert_eq!(grade_tone("F"), "fail");
}
}