use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
use zond_engine::Host;
use zond_engine::export::{Redaction, redact};
use zond_engine::model::host::status::StatusProtocol;
use zond_engine::model::ip::scoped::ScopedIp;
use zond_engine::scanner::report::{ScanKind, ScanPhase};
use zond_engine::{HostStatus, Port, PortState, Protocol, ScanReport, ScanSummary};
pub(crate) const UNKNOWN: &str = "-";
pub(crate) fn unknown() -> String {
UNKNOWN.to_owned()
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Reader {
redaction: Redaction,
}
impl Reader {
pub(crate) fn new(redaction: Redaction) -> Self {
Self { redaction }
}
pub(crate) fn addresses(self, host: &Host) -> String {
std::iter::once(self.primary(host))
.chain(self.others(host))
.collect::<Vec<_>>()
.join(",")
}
pub(crate) fn primary(self, host: &Host) -> String {
self.address(host, host.primary_ip())
}
pub(crate) fn other_addresses(self, host: &Host) -> Vec<String> {
self.others(host).collect()
}
pub(crate) fn primary_address(self, host: &Host) -> String {
let address = self.primary(host);
match host.ips().len().saturating_sub(1) {
0 => address,
others => format!("{address} +{others}"),
}
}
pub(crate) fn hostname(self, host: &Host) -> Option<String> {
host.hostname()
.map(|name| self.redaction.hostname(name).into_owned())
}
pub(crate) fn macs(self, host: &Host) -> Option<String> {
let hardware = host.hardware()?;
let recent = hardware.most_recent_mac();
let rest = hardware
.macs()
.keys()
.copied()
.filter(|mac| Some(*mac) != recent);
let combined: Vec<String> = recent
.into_iter()
.chain(rest)
.map(|mac| self.redaction.mac(&mac))
.collect();
if combined.is_empty() {
None
} else {
Some(combined.join(","))
}
}
fn others(self, host: &Host) -> impl Iterator<Item = String> + '_ {
let primary = host.primary_ip();
host.ips()
.iter()
.copied()
.filter(move |ip| *ip != primary)
.map(move |ip| self.address(host, ip))
}
fn address(self, host: &Host, ip: IpAddr) -> String {
let text = match ip {
IpAddr::V6(v6) if self.redaction.is_active() => mask(&v6),
_ => ip.to_string(),
};
match host.zone() {
Some(zone) if ScopedIp::needs_zone(&ip) => format!("{text}%{zone}"),
_ => text,
}
}
}
fn mask(ip: &Ipv6Addr) -> String {
let leading = ip.segments()[0];
if leading & 0xffc0 == 0xfe80 {
redact::link_local(ip)
} else if leading & 0xfe00 == 0xfc00 {
redact::unique_local(ip)
} else {
redact::global_unicast(ip)
}
}
pub(crate) fn status(host: &Host) -> String {
host.status().to_string()
}
pub(crate) fn is_up(host: &Host) -> bool {
host.status() == HostStatus::Up
}
fn protocol_names(host: &Host) -> Vec<String> {
let mut names: Vec<String> = host
.reasons()
.iter()
.map(|reason| protocol_name(&reason.protocol))
.collect();
names.sort_unstable();
names.dedup();
names
}
pub(crate) fn evidence(host: &Host) -> Option<String> {
let names = protocol_names(host);
(!names.is_empty()).then(|| names.join(","))
}
pub(crate) fn via(host: &Host) -> Option<String> {
let names = protocol_names(host);
(!names.is_empty()).then(|| names.join(", "))
}
fn protocol_name(protocol: &StatusProtocol) -> String {
match protocol {
StatusProtocol::Arp => "arp".to_owned(),
StatusProtocol::Ndp => "ndp".to_owned(),
StatusProtocol::IcmpEcho => "icmp_echo".to_owned(),
StatusProtocol::IcmpUnreachable => "icmp_unreachable".to_owned(),
StatusProtocol::TcpSyn => "tcp_syn".to_owned(),
StatusProtocol::Tcp => "tcp".to_owned(),
StatusProtocol::Udp => "udp".to_owned(),
StatusProtocol::Custom(name) => name.to_lowercase(),
other => format!("{other:?}").to_lowercase(),
}
}
pub(crate) fn vendor(host: &Host) -> Option<&str> {
host.vendor()
}
pub(crate) fn os(host: &Host) -> Option<String> {
host.os().map(ToString::to_string)
}
pub(crate) fn rtt_human(host: &Host) -> Option<String> {
let median = host.median_rtt()?;
let (Some(min), Some(max), Some(mean)) = (host.min_rtt(), host.max_rtt(), host.average_rtt())
else {
return Some(format_rtt(median));
};
if min == max {
return Some(format_rtt(median));
}
Some(format!(
"min {} avg {} max {}",
format_rtt(min),
format_rtt(mean),
format_rtt(max)
))
}
pub(crate) fn rtt_millis(host: &Host) -> Option<String> {
millis(host.median_rtt())
}
pub(crate) fn rtt_min_millis(host: &Host) -> Option<String> {
millis(host.min_rtt())
}
pub(crate) fn rtt_mean_millis(host: &Host) -> Option<String> {
millis(host.average_rtt())
}
pub(crate) fn rtt_max_millis(host: &Host) -> Option<String> {
millis(host.max_rtt())
}
fn millis(rtt: Option<Duration>) -> Option<String> {
rtt.map(|rtt| format!("{:.3}", rtt.as_secs_f64() * 1000.0))
}
fn format_rtt(rtt: Duration) -> String {
let millis = rtt.as_secs_f64() * 1000.0;
if millis >= 1000.0 {
format!("{:.2}s", millis / 1000.0)
} else if millis >= 10.0 {
format!("{millis:.1}ms")
} else {
format!("{millis:.2}ms")
}
}
const NO_SERVICE: &str = "???";
fn notable(state: PortState) -> bool {
state != PortState::Closed
}
pub(crate) fn ports(host: &Host) -> Vec<String> {
let mut shown: Vec<&Port> = host.ports().filter(|port| notable(port.state())).collect();
let closed = host.ports().filter(|port| !notable(port.state())).count();
if shown.is_empty() && closed == 0 {
return Vec::new();
}
shown.sort_by_key(|port| (port.state() != PortState::Open, port.number()));
let mut lines: Vec<String> = shown
.iter()
.map(|port| {
let mut line = format!(
"{}/{} {}",
port.number(),
protocol(port.protocol()),
state(port.state())
);
if let Some(service) = describe(port) {
line.push_str(" (");
line.push_str(&service);
line.push(')');
}
line
})
.collect();
if closed > 0 {
lines.push(format!(
"[{closed} closed {} omitted]",
plural(closed as u128, "port")
));
}
lines
}
pub(crate) fn plural(count: u128, word: &str) -> String {
if count == 1 {
word.to_owned()
} else if word.ends_with('s') {
format!("{word}es")
} else {
format!("{word}s")
}
}
pub(crate) fn packed_ports(host: &Host) -> Option<String> {
let mut ports: Vec<&Port> = host.ports().filter(|port| notable(port.state())).collect();
if ports.is_empty() {
return None;
}
ports.sort_by_key(|port| (port.protocol(), port.number()));
Some(
ports
.into_iter()
.map(|port| {
format!(
"{}/{}/{}/{}",
port.number(),
protocol(port.protocol()),
state(port.state()),
port.service_name().filter(named).unwrap_or(UNKNOWN)
)
})
.collect::<Vec<_>>()
.join(","),
)
}
pub(crate) fn closed_ports(host: &Host) -> Option<String> {
let closed = host.ports().filter(|port| !notable(port.state())).count();
if host.port_count() == 0 {
None
} else {
Some(closed.to_string())
}
}
fn named(name: &&str) -> bool {
*name != NO_SERVICE
}
fn protocol(protocol: Protocol) -> String {
match protocol {
Protocol::Tcp => "tcp".to_owned(),
Protocol::Udp => "udp".to_owned(),
other => format!("{other:?}").to_lowercase(),
}
}
fn state(state: PortState) -> String {
match state {
PortState::Open => "open".to_owned(),
PortState::Closed => "closed".to_owned(),
PortState::Filtered => "filtered".to_owned(),
PortState::Unfiltered => "unfiltered".to_owned(),
PortState::OpenFiltered => "open-filtered".to_owned(),
PortState::ClosedFiltered => "closed-filtered".to_owned(),
other => format!("{other:?}").to_lowercase(),
}
}
fn describe(port: &Port) -> Option<String> {
let service = port.service()?;
if !named(&service.name()) {
return None;
}
let mut described = service.name().to_owned();
if let Some(product) = service.product() {
described.push(' ');
described.push_str(product);
}
if let Some(version) = service.version() {
described.push(' ');
described.push_str(version);
}
Some(described)
}
pub(crate) fn sorted_hosts(report: &ScanReport) -> Vec<&Host> {
let mut hosts: Vec<&Host> = report.hosts().collect();
hosts.sort_by_key(|host| host.primary_ip());
hosts
}
pub(crate) fn addresses_scanned(report: &ScanReport) -> u128 {
report
.phases()
.first()
.map_or(0, |phase| phase.targets().addresses())
}
pub(crate) fn skipped_as_down(report: &ScanReport) -> u128 {
let [liveness, ports, ..] = report.phases() else {
return 0;
};
liveness
.targets()
.addresses()
.saturating_sub(ports.targets().addresses())
}
pub(crate) fn kind(report: &ScanReport) -> Option<ScanKind> {
report.phases().last().map(ScanPhase::kind)
}
pub(crate) fn was_privileged(report: &ScanReport) -> bool {
report.phases().last().is_some_and(ScanPhase::privileged)
}
pub(crate) fn summary(report: &ScanReport) -> ScanSummary {
report.summary()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::test_support::host;
use std::net::Ipv4Addr;
use zond_engine::Service;
use zond_engine::model::host::status::{StatusProtocol, StatusReason};
use zond_engine::model::ip::scoped::Zone;
fn v6(text: &str) -> Ipv6Addr {
text.parse().expect("a valid address")
}
fn ms(millis: u64) -> Duration {
Duration::from_micros(millis * 1000)
}
fn scanned() -> Host {
let mut host = host(1);
host.add_port(
Port::new(22, Protocol::Tcp, PortState::Open).with_service(
Service::new("ssh", 100)
.with_product("OpenSSH")
.with_version("9.6"),
),
);
host.add_port(Port::new(443, Protocol::Tcp, PortState::Open));
host.add_port(Port::new(53, Protocol::Udp, PortState::Open));
host.add_port(Port::new(21, Protocol::Tcp, PortState::Filtered));
host.add_port(Port::new(80, Protocol::Tcp, PortState::Closed));
host
}
#[test]
fn an_ipv6_address_is_masked_by_what_kind_of_address_it_is() {
assert_eq!(
mask(&v6("fe80::200:5eff:fe00:5301")),
"fe80::200:5eff:XXXX:XXXX"
);
assert_eq!(
mask(&v6("febf::1")),
"febf::0:0:XXXX:XXXX",
"the top of fe80::/10"
);
assert_eq!(mask(&v6("fc00::1")), "fc00::XXXX");
assert_eq!(
mask(&v6("fd12:3456:789a::1")),
"fd12::XXXX",
"the global ID goes"
);
assert_eq!(mask(&v6("2001:db8::1")), "2001::XXXX");
}
#[test]
fn site_local_is_not_mistaken_for_unique_local() {
assert_eq!(mask(&v6("fec0::1")), "fec0::XXXX");
}
#[test]
fn redaction_reaches_the_name_and_the_hardware() {
let mut host = host(1);
host.set_hostname(Some("router.example".to_owned()));
host.record_mac("00:00:5e:00:53:01".parse().expect("a valid address"));
let plain = Reader::default();
assert_eq!(plain.hostname(&host).as_deref(), Some("router.example"));
assert_eq!(plain.macs(&host).as_deref(), Some("00:00:5e:00:53:01"));
let masked = Reader::new(Redaction::Standard);
assert_ne!(masked.hostname(&host).as_deref(), Some("router.example"));
assert_ne!(masked.macs(&host).as_deref(), Some("00:00:5e:00:53:01"));
}
#[test]
fn a_zone_survives_masking() {
let mut host = Host::new(IpAddr::V6(v6("fe80::1")));
host.set_zone(Zone::new(4, "en0"));
assert!(
Reader::new(Redaction::Standard)
.primary(&host)
.ends_with("%en0"),
"{}",
Reader::new(Redaction::Standard).primary(&host)
);
}
#[test]
fn every_address_is_listed_primary_first() {
let mut host = host(1);
host.add_ip(IpAddr::V6(v6("2001:db8::1")));
assert_eq!(Reader::default().addresses(&host), "192.0.2.1,2001:db8::1");
assert_eq!(
Reader::default().other_addresses(&host),
vec!["2001:db8::1".to_owned()]
);
}
#[test]
fn the_progress_line_counts_the_other_addresses() {
let mut host = host(1);
assert_eq!(Reader::default().primary_address(&host), "192.0.2.1");
host.add_ip(IpAddr::V6(v6("2001:db8::1")));
assert_eq!(Reader::default().primary_address(&host), "192.0.2.1 +1");
}
#[test]
fn a_host_at_one_address_has_no_others() {
assert!(Reader::default().other_addresses(&host(1)).is_empty());
}
#[test]
fn evidence_is_sorted_and_names_each_protocol_once() {
let mut answered = host(1);
for (protocol, detail) in [
(StatusProtocol::TcpSyn, "syn-ack"),
(StatusProtocol::Ndp, "advertisement"),
(StatusProtocol::IcmpEcho, "reply"),
(StatusProtocol::Arp, "reply"),
(StatusProtocol::Arp, "gratuitous"),
] {
answered.add_reason(StatusReason::new(protocol, detail));
}
assert_eq!(
evidence(&answered).as_deref(),
Some("arp,icmp_echo,ndp,tcp_syn"),
"the record format cannot afford a space inside a field"
);
assert_eq!(
via(&answered).as_deref(),
Some("arp, icmp_echo, ndp, tcp_syn"),
"and a line somebody reads wants one"
);
assert_eq!(evidence(&host(2)), None, "nothing answered");
assert_eq!(via(&host(2)), None);
}
#[test]
fn a_readable_time_changes_unit_with_its_magnitude() {
assert_eq!(format_rtt(Duration::from_micros(1_420)), "1.42ms");
assert_eq!(format_rtt(Duration::from_micros(8_200)), "8.20ms");
assert_eq!(
format_rtt(ms(10)),
"10.0ms",
"the first millisecond at one decimal"
);
assert_eq!(format_rtt(ms(412)), "412.0ms");
assert_eq!(format_rtt(ms(1_000)), "1.00s", "the first second");
assert_eq!(format_rtt(ms(1_240)), "1.24s");
}
#[test]
fn a_machine_readable_time_stays_in_milliseconds() {
let mut quick = host(1);
quick.add_rtt(Duration::from_micros(412));
assert_eq!(rtt_millis(&quick).as_deref(), Some("0.412"));
let mut slow = host(2);
slow.add_rtt(ms(1_240));
assert_eq!(rtt_millis(&slow).as_deref(), Some("1240.000"));
}
#[test]
fn a_time_with_no_spread_prints_as_one_figure() {
let mut host = host(1);
host.add_rtt(Duration::from_micros(1_420));
assert_eq!(rtt_human(&host).as_deref(), Some("1.42ms"));
}
#[test]
fn a_time_with_a_spread_shows_all_of_it() {
let mut host = host(1);
host.add_rtt(ms(1));
host.add_rtt(ms(50));
let shown = rtt_human(&host).expect("two samples");
assert!(shown.contains("min"), "{shown}");
assert!(shown.contains("max"), "{shown}");
}
#[test]
fn a_host_that_never_answered_has_no_time() {
assert_eq!(rtt_human(&host(1)), None);
assert_eq!(rtt_millis(&host(1)), None);
}
#[test]
fn ports_are_listed_open_first_with_the_closed_ones_counted() {
assert_eq!(
ports(&scanned()),
vec![
"22/tcp open (ssh OpenSSH 9.6)",
"53/udp open",
"443/tcp open",
"21/tcp filtered",
"[1 closed port omitted]",
]
);
}
#[test]
fn a_host_with_nothing_open_says_so() {
let mut host = host(1);
host.add_port(Port::new(80, Protocol::Tcp, PortState::Closed));
assert_eq!(ports(&host), vec!["[1 closed port omitted]"]);
}
#[test]
fn the_closed_rollup_counts_in_the_plural() {
let mut host = host(1);
for number in [80, 443] {
host.add_port(Port::new(number, Protocol::Tcp, PortState::Closed));
}
assert_eq!(ports(&host), vec!["[2 closed ports omitted]"]);
}
#[test]
fn a_host_that_was_never_port_scanned_has_no_port_lines() {
assert!(ports(&host(1)).is_empty());
assert_eq!(closed_ports(&host(1)), None);
}
#[test]
fn packed_ports_carry_number_protocol_state_and_service() {
assert_eq!(
packed_ports(&scanned()).as_deref(),
Some("21/tcp/filtered/-,22/tcp/open/ssh,443/tcp/open/-,53/udp/open/-"),
"grouped by protocol, then by number — a different order from the listing"
);
assert_eq!(closed_ports(&scanned()).as_deref(), Some("1"));
}
#[test]
fn the_no_service_sentinel_is_never_shown_as_a_name() {
let mut host = host(1);
host.add_port(
Port::new(9999, Protocol::Tcp, PortState::Open)
.with_service(Service::new(NO_SERVICE, 0)),
);
assert_eq!(ports(&host), vec!["9999/tcp open"]);
assert_eq!(packed_ports(&host).as_deref(), Some("9999/tcp/open/-"));
}
#[test]
fn a_compound_port_state_keeps_its_hyphen() {
assert_eq!(state(PortState::Open), "open");
assert_eq!(state(PortState::OpenFiltered), "open-filtered");
assert_eq!(state(PortState::ClosedFiltered), "closed-filtered");
assert_eq!(protocol(Protocol::Tcp), "tcp");
assert_eq!(protocol(Protocol::Udp), "udp");
}
#[test]
fn a_protocol_that_proved_a_host_alive_has_a_stable_name() {
assert_eq!(protocol_name(&StatusProtocol::Arp), "arp");
assert_eq!(protocol_name(&StatusProtocol::IcmpEcho), "icmp_echo");
assert_eq!(protocol_name(&StatusProtocol::TcpSyn), "tcp_syn");
}
#[test]
fn a_status_is_only_shown_when_it_is_not_simply_up() {
assert!(is_up(&host(1)));
assert_eq!(status(&host(1)), "Up");
let down = Host::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 9)));
assert!(!is_up(&down));
}
}