use std::io::{self, BufWriter, Write};
use zond_engine::export::Redaction;
use zond_engine::{Host, ScanReport};
use crate::diagnostics::Verbosity;
use crate::render::narrate::Narrator;
use crate::render::{Phase, Renderer, field};
const BULLET: &str = "* ";
const INDENT: &str = " ";
const TAG_WIDTH: usize = 5;
const VALUE_COLUMN: usize = INDENT.len() + TAG_WIDTH + 1;
pub(crate) struct MinimalRenderer {
records: Box<dyn Write>,
narrator: Narrator,
reader: field::Reader,
}
impl MinimalRenderer {
#[must_use]
pub(crate) fn to_terminal(verbosity: Verbosity) -> Self {
Self::new(
Box::new(BufWriter::new(io::stdout())),
Box::new(io::stderr()),
verbosity,
)
}
#[must_use]
pub(crate) fn new(
records: Box<dyn Write>,
narration: Box<dyn Write>,
verbosity: Verbosity,
) -> Self {
Self {
records,
narrator: Narrator::new(narration, verbosity),
reader: field::Reader::default(),
}
}
}
fn write_host(out: &mut dyn Write, reader: field::Reader, host: &Host) -> io::Result<()> {
match reader.hostname(host) {
Some(name) => writeln!(out, "{BULLET}{} [{name}]", reader.primary(host))?,
None => writeln!(out, "{BULLET}{}", reader.primary(host))?,
}
if !field::is_up(host) {
tag(out, "status", &field::status(host))?;
}
if let Some(macs) = reader.macs(host) {
let line = match field::vendor(host) {
Some(vendor) => format!("{macs} ({vendor})"),
None => macs,
};
tag(out, "mac", &line)?;
}
for (name, value) in [
("os", field::os(host)),
("rtt", field::rtt_human(host)),
("via", field::via(host)),
] {
if let Some(value) = value {
tag(out, name, &value)?;
}
}
tagged_list(out, "also", &reader.other_addresses(host))?;
tagged_list(out, "port", &field::ports(host))?;
Ok(())
}
fn tag(out: &mut dyn Write, name: &str, value: &str) -> io::Result<()> {
writeln!(out, "{INDENT}{:<TAG_WIDTH$} {value}", format!("{name}:"))
}
fn tagged_list(out: &mut dyn Write, name: &str, values: &[String]) -> io::Result<()> {
for (index, value) in values.iter().enumerate() {
if index == 0 {
tag(out, name, value)?;
} else {
writeln!(out, "{:VALUE_COLUMN$}{value}", "")?;
}
}
Ok(())
}
impl Renderer for MinimalRenderer {
fn started(&mut self, phase: Phase<'_>, redaction: Redaction) -> io::Result<()> {
self.reader = field::Reader::new(redaction);
self.narrator.started(phase, redaction)
}
fn host_found(&mut self, host: &Host) -> io::Result<()> {
self.narrator.found(host)
}
fn interrupted(&mut self) -> io::Result<()> {
self.narrator.interrupted()
}
fn finished(&mut self, report: &ScanReport) -> io::Result<()> {
let hosts = field::sorted_hosts(report);
for (index, host) in hosts.iter().enumerate() {
if index > 0 || self.narrator.narrates() {
writeln!(self.records)?;
}
write_host(&mut self.records, self.reader, host)?;
}
self.records.flush()?;
self.narrator.summary(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::test_support::{Capture, host};
use std::net::{IpAddr, Ipv4Addr};
use std::time::Duration;
use zond_engine::export::Redaction;
use zond_engine::model::host::status::{StatusProtocol, StatusReason};
use zond_engine::model::ip::scoped::Zone;
use zond_engine::{HostStatus, Port, PortState, Protocol, Service};
fn block(host: &Host) -> String {
rendered(field::Reader::default(), host)
}
fn rendered(reader: field::Reader, host: &Host) -> String {
let mut out = Vec::new();
write_host(&mut out, reader, host).expect("a vector cannot fail");
String::from_utf8(out).expect("the renderer writes text")
}
fn furnished() -> Host {
let mut host = host(1);
host.add_ip("fe80::1".parse().expect("a valid address"));
host.set_zone(Zone::new(4, "en0"));
host.add_rtt(Duration::from_micros(1_420));
host.add_reason(StatusReason::new(StatusProtocol::Arp, "reply"));
host.add_reason(StatusReason::new(StatusProtocol::Ndp, "advertisement"));
host.record_mac("00:00:5e:00:53:01".parse().expect("a valid address"));
host.set_hostname(Some("router.example".to_owned()));
host
}
#[test]
fn a_furnished_host_reads_as_a_tagged_block() {
assert_eq!(
block(&furnished()),
"* 192.0.2.1 [router.example]\n\
\x20 mac: 00:00:5e:00:53:01 (Icann, Iana Department)\n\
\x20 rtt: 1.42ms\n\
\x20 via: arp, ndp\n\
\x20 also: fe80::1%en0\n"
);
}
#[test]
fn a_host_with_no_name_opens_with_the_address_alone() {
let mut host = furnished();
host.set_hostname(None);
assert!(
block(&host).starts_with("* 192.0.2.1\n"),
"{}",
block(&host)
);
}
#[test]
fn the_vendor_sits_on_the_hardware_line() {
assert!(
block(&furnished()).contains("mac: 00:00:5e:00:53:01 (Icann, Iana Department)"),
"{}",
block(&furnished())
);
}
#[test]
fn further_addresses_get_a_line_each() {
let mut host = furnished();
host.add_ip("2001:db8::1".parse().expect("a valid address"));
let text = block(&host);
let also: Vec<&str> = text
.lines()
.skip_while(|line| !line.contains("also:"))
.collect();
assert_eq!(also.len(), 2, "{text}");
assert!(also[0].starts_with(" also: "), "{text}");
assert_eq!(
&also[1][..VALUE_COLUMN],
" ".repeat(VALUE_COLUMN),
"a continued value is aligned under the first: {text}"
);
}
#[test]
fn ports_are_tagged_once_and_listed_under_it() {
let mut host = host(1);
host.add_port(
Port::new(22, Protocol::Tcp, PortState::Open)
.with_service(Service::new("ssh", 100).with_product("OpenSSH")),
);
host.add_port(Port::new(80, Protocol::Tcp, PortState::Closed));
let text = block(&host);
assert!(text.contains(" port: 22/tcp open (ssh OpenSSH)"), "{text}");
assert!(text.contains("\n [1 closed port omitted]"), "{text}");
}
#[test]
fn redaction_masks_the_name_the_hardware_and_the_address() {
let masked = rendered(field::Reader::new(Redaction::Standard), &furnished());
assert!(!masked.contains("router.example"), "{masked}");
assert!(!masked.contains("00:00:5e:00:53:01"), "{masked}");
}
#[test]
fn a_link_local_address_cannot_give_back_the_hardware_address() {
let mut host = host(1);
host.set_zone(Zone::new(4, "en0"));
host.add_ip("fe80::200:5eff:fe00:5301".parse().expect("a valid address"));
host.record_mac("00:00:5e:00:53:01".parse().expect("a valid address"));
let masked = rendered(field::Reader::new(Redaction::Standard), &host);
assert!(
!masked.contains("fe00:5301"),
"the host part survived masking: {masked}"
);
}
#[test]
fn nothing_is_masked_by_default() {
let plain = block(&furnished());
assert!(plain.contains("router.example"), "{plain}");
assert!(plain.contains("00:00:5e:00:53:01"), "{plain}");
}
#[test]
fn a_host_nothing_is_known_about_carries_no_empty_lines() {
let mut host = host(36);
host.add_rtt(Duration::from_micros(8_200));
host.record_mac("02:00:5e:00:53:04".parse().expect("a valid address"));
host.add_reason(StatusReason::new(StatusProtocol::Arp, "reply"));
assert_eq!(
block(&host),
"* 192.0.2.36\n\
\x20 mac: 02:00:5e:00:53:04\n\
\x20 rtt: 8.20ms\n\
\x20 via: arp\n"
);
}
#[test]
fn an_unknown_fact_produces_no_line() {
let bare = block(&host(1));
assert_eq!(bare, "* 192.0.2.1\n");
for absent in ["mac:", "rtt:", "os:", "via:", "also:", "port:", "[", "-"] {
assert!(!bare.contains(absent), "{absent:?} in {bare:?}");
}
}
#[test]
fn status_appears_only_when_it_is_not_simply_up() {
assert!(!block(&host(1)).contains("status"));
let mut filtered = Host::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 9)));
filtered.set_status(HostStatus::Filtered);
assert!(block(&filtered).contains("status: Filtered"));
}
#[test]
fn every_tag_lines_its_value_up_with_the_others() {
let text = block(&furnished());
let mut lines = text.lines();
assert!(lines.next().expect("a header line").starts_with(BULLET));
for line in lines {
assert!(line.starts_with(INDENT), "not indented: {line:?}");
assert!(
!line[VALUE_COLUMN..].starts_with(' '),
"value does not begin at column {VALUE_COLUMN}: {line:?}"
);
}
}
#[test]
fn a_link_local_primary_keeps_its_zone() {
let mut host = Host::new("fe80::9".parse().expect("a valid address"));
host.set_status(HostStatus::Up);
host.set_zone(Zone::new(4, "en0"));
host.add_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
let text = block(&host);
assert!(text.starts_with("* fe80::9%en0\n"), "{text}");
assert!(text.contains("also: 10.0.0.1"), "{text}");
}
fn renderer(verbosity: Verbosity) -> (MinimalRenderer, Capture, Capture) {
let records = Capture::default();
let narration = Capture::default();
let renderer = MinimalRenderer::new(
Box::new(records.clone()),
Box::new(narration.clone()),
verbosity,
);
(renderer, records, narration)
}
#[test]
fn progress_never_reaches_the_record_stream() {
let (mut renderer, records, narration) = renderer(Verbosity::default());
renderer.host_found(&host(1)).expect("capture cannot fail");
renderer.host_found(&host(2)).expect("capture cannot fail");
assert_eq!(records.text(), "");
assert!(narration.text().contains("found 192.0.2.1"));
assert!(narration.text().contains("found 192.0.2.2"));
}
#[test]
fn a_host_is_announced_once_however_often_it_is_updated() {
let (mut renderer, _records, narration) = renderer(Verbosity::default());
for _ in 0..5 {
renderer.host_found(&host(1)).expect("capture cannot fail");
}
assert_eq!(narration.text().matches("found 192.0.2.1").count(), 1);
}
#[test]
fn quiet_narrates_nothing() {
let (mut renderer, _records, narration) = renderer(Verbosity::new(0, true));
renderer.host_found(&host(1)).expect("capture cannot fail");
renderer.interrupted().expect("capture cannot fail");
assert_eq!(narration.text(), "");
}
}