use std::{net::IpAddr, time::Duration};
use colored::*;
use unicode_width::UnicodeWidthStr;
use zond_engine::core::models::host::Host;
use zond_engine::core::models::port::{Port, PortState, Protocol, Service};
use crate::{
terminal::{
colors, format,
print::{self, Detail, Print, TOTAL_WIDTH},
},
zprint,
};
pub(crate) trait PrintableHost {
fn print(&self, index: usize);
}
impl PrintableHost for Host {
fn print(&self, index: usize) {
let p = Print::get();
let primary_ip: IpAddr = self.primary_ip();
print_host_head(index, &primary_ip, self);
let mut details = format::ip_to_detail(self, p.redact);
if let Some(mac_detail) = format::mac_to_detail(self.mac(), p.redact) {
details.push(mac_detail);
}
if let Some(vendor_detail) = format::vendor_to_detail(self.vendor()) {
details.push(vendor_detail);
}
if let Some(hostname_detail) = format::hostname_to_detail(self.hostname(), p.redact) {
details.push(hostname_detail);
}
let ports: Vec<_> = self.ports().cloned().collect();
print::as_tree(details, !ports.is_empty());
if !ports.is_empty() {
print_services(&ports);
}
}
}
fn print_host_head(idx: usize, primary_ip: &IpAddr, host: &Host) {
let rtt_string: String = rtt_to_string(host);
let rtt_width: usize = rtt_string.width();
let block_width: usize = 20;
let local_pad: usize = block_width.saturating_sub(rtt_width);
let right_part: String = format!("{}{}", " ".repeat(local_pad), rtt_string);
let left_part: String = format!("[{}] {}", idx, primary_ip);
let used_width: usize = left_part.width() + block_width;
let padding_len: usize = TOTAL_WIDTH.saturating_sub(used_width + 1);
let padding: String = " ".repeat(padding_len);
zprint!(
"{} {}{}{}",
format!("[{}]", idx.to_string().color(colors::ACCENT)).color(colors::SEPARATOR),
primary_ip.to_string().color(colors::PRIMARY),
padding,
right_part.color(colors::SECONDARY)
);
}
fn rtt_to_string(host: &Host) -> String {
let Some(median) = host.median_rtt() else {
return String::new();
};
format!("⌛ {}", format_rtt(median))
}
fn format_rtt(rtt: Duration) -> String {
let ms = rtt.as_secs_f64() * 1000.0;
let value = if ms < 10.0 {
format!("{ms:.2}")
} else if ms < 100.0 {
format!("{ms:.1}")
} else {
format!("{ms:.0}")
};
format!("{value:>4}ms")
}
fn print_services(ports: &[Port]) {
let mut open_c = 0;
let mut filtered_c = 0;
let mut _closed_c = 0;
for p in ports {
match p.state() {
PortState::Open => open_c += 1,
PortState::Filtered | PortState::OpenFiltered => filtered_c += 1,
PortState::Closed => _closed_c += 1,
_ => (),
}
}
let mut stats = Vec::new();
if open_c > 0 {
stats.push(format!("{} OPEN", open_c).green().bold().to_string());
}
if filtered_c > 0 {
stats.push(format!("{} FILTERED", filtered_c).cyan().bold().to_string());
}
let stats_str = if stats.is_empty() {
"ALL CHECKS CLOSED".dimmed().to_string()
} else {
stats.join(&format!("{}", " / ".bright_black().bold()))
};
let label = "Services";
let dots = ".".repeat("Hostname".len().saturating_sub(label.len()));
zprint!(
" {} {}{}{} {}",
"└─".bright_black(),
label.color(colors::TEXT_DEFAULT),
dots.color(colors::SEPARATOR),
":".color(colors::SEPARATOR),
stats_str
);
let detailed = Print::get().detailed;
for (i, p) in ports.iter().enumerate() {
let last = i + 1 == ports.len();
let branch = if !last { "├─" } else { "└─" }.bright_black();
let proto_str = match p.protocol() {
Protocol::Tcp => "tcp",
Protocol::Udp => "udp",
Protocol::Sctp => "sctp",
};
let port_spec = format!("{}/{}", p.number(), proto_str);
let port_spec_padded = format!("{:width$}", port_spec, width = 9);
let (state_str, state_color) = match p.state() {
PortState::Open => ("OPEN", Color::Green),
PortState::Filtered => ("FILTERED", Color::Cyan),
PortState::OpenFiltered => ("OPEN|FIL", Color::Yellow),
PortState::Closed => ("CLOSED", Color::Red),
_ => ("UNKNOWN", Color::White),
};
const STATE_FIELD: usize = 12;
let badge_width = state_str.len() + 4; let state_pad = " ".repeat(STATE_FIELD.saturating_sub(badge_width));
let state_fmt = format!("[ {} ]{}", state_str.color(state_color), state_pad);
let expand = detailed && p.service().is_some_and(service_has_evidence);
let trailing = if expand {
String::new()
} else {
format!(" {}", service_summary(p.service(), p.service_name()))
};
zprint!(
" {} {} {}{}",
branch,
port_spec_padded.color(colors::PORT),
state_fmt,
trailing
);
if expand {
print_service_detail(p.service(), last);
}
}
}
fn service_has_evidence(service: &Service) -> bool {
service.product().is_some()
|| service.version().is_some()
|| service.vendor().is_some()
|| service.extrainfo().is_some()
|| !service.cpe().is_empty()
|| service.confidence() > 0
}
fn service_summary(service: Option<&Service>, name: Option<&str>) -> String {
let name = name.unwrap_or("???");
let mut out = name.color(colors::TEXT_DEFAULT).to_string();
let Some(service) = service else {
return out;
};
if let Some(product) = service.product().filter(|p| *p != name) {
out.push(' ');
out.push_str(&product.color(colors::SECONDARY).bold().to_string());
}
if let Some(version) = service.version() {
out.push(' ');
out.push_str(&version.bright_black().to_string());
}
if let Some(extra) = service.extrainfo() {
out.push(' ');
out.push_str(&format!("({extra})").bright_black().italic().to_string());
}
out
}
fn print_service_detail(service: Option<&Service>, port_last: bool) {
let Some(service) = service else {
return;
};
let name = service.name();
let mut rows: Vec<Detail> = Vec::new();
rows.push(("service".into(), name.color(colors::SECONDARY).bold()));
if let Some(product) = service.product().filter(|p| *p != name) {
rows.push(("product".into(), product.color(colors::SECONDARY).bold()));
}
if let Some(version) = service.version() {
rows.push(("version".into(), version.color(colors::TEXT_DEFAULT)));
}
if let Some(vendor) = service.vendor() {
rows.push(("vendor".into(), vendor.color(colors::TEXT_DEFAULT)));
}
if let Some(extra) = service.extrainfo() {
rows.push(("extrainfo".into(), extra.color(colors::TEXT_DEFAULT)));
}
for cpe in service.cpe() {
rows.push(("cpe".into(), cpe.color(colors::SECONDARY)));
}
if service.confidence() > 0 {
rows.push(("confidence".into(), confidence_bar(service.confidence())));
}
let guide = if port_last { " " } else { "│ " }.bright_black();
let label_width = "confidence".len();
for (i, (label, value)) in rows.iter().enumerate() {
let last = i + 1 == rows.len();
let dbranch = if !last { "├─" } else { "└─" }.bright_black();
let dots = ".".repeat(label_width.saturating_sub(label.len()));
zprint!(
" {} {} {}{}{} {}",
guide,
dbranch,
label.color(colors::TEXT_DEFAULT),
dots.color(colors::SEPARATOR),
":".color(colors::SEPARATOR),
value
);
}
}
fn confidence_bar(score: u8) -> ColoredString {
const CELLS: u8 = 10;
let filled = ((score as u16 * CELLS as u16 + 50) / 100) as u8;
let filled = filled.clamp(1, CELLS);
let color = match score {
90..=100 => Color::Green,
70..=89 => Color::TrueColor {
r: 154,
g: 205,
b: 50,
}, 40..=69 => Color::Yellow,
_ => Color::Red,
};
let meter = format!(
"{}{}",
"▰".repeat(filled as usize),
"▱".repeat((CELLS - filled) as usize)
);
format!("{} {:>3}%", meter.color(color), score).color(colors::TEXT_DEFAULT)
}