use std::collections::HashSet;
use std::io::{self, Write};
use std::net::IpAddr;
use zond_engine::export::Redaction;
use zond_engine::scanner::report::ScanKind;
use zond_engine::{Host, ScanReport};
use crate::diagnostics::Verbosity;
use crate::render::field::plural;
use crate::render::{Phase, field};
pub(crate) struct Narrator {
out: Box<dyn Write>,
verbosity: Verbosity,
announced: HashSet<IpAddr>,
reader: field::Reader,
}
impl Narrator {
pub(crate) fn new(out: Box<dyn Write>, verbosity: Verbosity) -> Self {
Self {
out,
verbosity,
announced: HashSet::new(),
reader: field::Reader::default(),
}
}
pub(crate) fn redact(&mut self, redaction: Redaction) {
self.reader = field::Reader::new(redaction);
}
pub(crate) fn narrates(&self) -> bool {
self.verbosity.narrates()
}
fn say(&mut self, line: &str) -> io::Result<()> {
if !self.verbosity.narrates() {
return Ok(());
}
writeln!(self.out, "{line}")
}
pub(crate) fn started(&mut self, phase: Phase<'_>, redaction: Redaction) -> io::Result<()> {
self.redact(redaction);
let line = match phase {
Phase::Discovery { targets } => {
let count = targets.len();
format!(
"discovering {count} {} ({targets})",
plural(count, "address")
)
}
Phase::PortScan { targets } => {
let hosts = targets.hosts();
let probes = targets.probes();
format!(
"scanning {probes} {} across {hosts} {} ({targets})",
plural(probes, "probe"),
plural(hosts, "host"),
)
}
};
self.say(&line)?;
self.out.flush()
}
pub(crate) fn found(&mut self, host: &Host) -> io::Result<()> {
if !self.announced.insert(host.primary_ip()) {
return Ok(());
}
self.say(&format!("found {}", self.reader.primary_address(host)))?;
self.out.flush()
}
pub(crate) fn interrupted(&mut self) -> io::Result<()> {
self.say("interrupted; stopping and reporting what was found so far")?;
self.out.flush()
}
pub(crate) fn summary(&mut self, report: &ScanReport) -> io::Result<()> {
let summary = field::summary(report);
let scanned = field::addresses_scanned(report);
let elapsed = report.elapsed().as_secs_f64();
self.say("")?;
self.say(&format!(
"{} host{} up of {scanned} address{} in {elapsed:.2}s",
summary.hosts_alive,
if summary.hosts_alive == 1 { "" } else { "s" },
if scanned == 1 { "" } else { "es" },
))?;
if summary.ports_total > 0 {
self.say(&format!(
"{} open port{} of {} probed",
summary.ports_open,
if summary.ports_open == 1 { "" } else { "s" },
summary.ports_total,
))?;
}
if !field::was_privileged(report) {
self.say(match field::kind(report) {
Some(ScanKind::PortScan) => {
"note: ran without raw sockets, so every port was tested by \
completing a connection. Run with sudo for SYN scanning, \
which is faster, less visible, and the only way to ask a \
port anything other than \"will you accept\"."
}
_ => {
"note: ran without raw sockets, so this was TCP connect \
attempts against a few common ports. Run with sudo for ARP \
and ICMPv6 discovery, which finds hosts this cannot."
}
})?;
}
let skipped = field::skipped_as_down(report);
if skipped > 0 {
self.say(&format!(
"note: {skipped} {} answered no liveness probe and {} not port-scanned. \
Pass --assume-up to probe {} anyway.",
plural(skipped, "address"),
if skipped == 1 { "was" } else { "were" },
if skipped == 1 { "it" } else { "them" },
))?;
}
if report.is_partial() {
let failures = report.failures().count();
self.say(&format!(
"warning: {failures} strateg{} did not run; this scan covered less \
than it was asked to",
if failures == 1 { "y" } else { "ies" },
))?;
}
self.out.flush()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_word_ending_in_s_takes_es() {
assert_eq!(plural(16, "address"), "addresses");
assert_eq!(plural(1, "address"), "address");
}
#[test]
fn every_other_word_takes_s() {
assert_eq!(plural(0, "host"), "hosts");
assert_eq!(plural(1, "host"), "host");
assert_eq!(plural(3, "probe"), "probes");
}
}