#![doc = env!("CARGO_PKG_DESCRIPTION")]
#![doc = ""]
#![cfg_attr(doc, doc = include_str!("../README.md"))]
#![doc(
html_logo_url = "https://raw.githubusercontent.com/0xdea/singsing-rs/master/.img/logo_zucchini.png"
)]
use std::fmt;
use std::io::{self, Write};
use std::net::Ipv4Addr;
use std::process::ExitCode;
use std::str::FromStr;
use std::time::{Duration, Instant};
use anyhow::Context as _;
use chrono::{DateTime, Local, TimeZone};
use clap::Parser;
use singsing_rs::{
CallbackError, Port, PortState, PortsError, ScanConfig, ScanError, ScanProgress, ScanResult,
TargetsError, interface_ipv4, parse_ports, parse_targets, ports_from_services,
scan_with_callbacks,
};
const PROGRAM: &str = env!("CARGO_BIN_NAME");
const VERSION: &str = env!("CARGO_PKG_VERSION");
const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
#[derive(Clone, Debug, Eq, PartialEq)]
struct Targets(Vec<Ipv4Addr>);
impl FromStr for Targets {
type Err = TargetsError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
parse_targets(input).map(Self)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Ports(Vec<Port>);
impl FromStr for Ports {
type Err = PortsError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
parse_ports(input).map(Self)
}
}
#[derive(Debug, Parser)]
#[command(name = PROGRAM, disable_help_flag = true, about = None)]
struct Arguments {
#[arg(short = 'i', long)]
interface: String,
#[arg(short = 'h', long)]
host: Targets,
#[arg(short = 'p', long)]
ports: Option<Ports>,
#[arg(short = 'c', long)]
closed: bool,
#[arg(short = 'b', long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
bandwidth: u64,
#[arg(short = 't', long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
timeout: u64,
#[arg(short = 'v', long)]
verbose: bool,
#[arg(long, action = clap::ArgAction::Help)]
help: Option<bool>,
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("[!] Error: {error:#}");
ExitCode::FAILURE
}
}
}
fn run() -> anyhow::Result<()> {
write_banner()?;
let args = Arguments::parse();
let Targets(host) = args.host;
let ports = args
.ports
.map(|Ports(ports)| ports)
.map_or_else(|| ports_from_services("/etc/services"), Ok)?;
let source = interface_ipv4(&args.interface)?;
let mut config = ScanConfig::new(host, ports, source);
config.show_closed = args.closed;
config.bandwidth_kib = args.bandwidth;
config.timeout = Duration::from_secs(args.timeout);
let probes = config
.targets
.len()
.checked_mul(config.ports.len())
.context("scan size overflow")?;
write_scan_summary(probes, &args.interface, source)?;
let started = Instant::now();
let verbose = args.verbose;
let scan_result = scan_with_callbacks(
&config,
move |result| {
if verbose {
write_verbose_result(result)
} else {
Ok(())
}
.map_err(|error| to_boxed_error(&error))
},
move |progress| write_progress(progress).map_err(|error| to_boxed_error(&error)),
);
match scan_result {
Ok(results) => write_results(&results)?,
Err(error) => {
if let ScanError::Incomplete(incomplete) = &error {
write_incomplete_summary(incomplete.probes_sent(), incomplete.total_probes())?;
write_results(incomplete.partial_results())?;
}
return Err(error.into());
}
}
write_done_summary(probes, started.elapsed().as_secs_f64())
}
fn to_boxed_error(error: &anyhow::Error) -> CallbackError {
format!("{error:#}").into()
}
fn write_banner() -> anyhow::Result<()> {
let stderr = io::stderr();
let mut output = stderr.lock();
write_banner_to(&mut output)?;
output.flush().context("failed to flush output stream")
}
fn write_banner_to(output: &mut impl Write) -> anyhow::Result<()> {
write!(
output,
"{PROGRAM} {VERSION} - {DESCRIPTION}\nCopyright (c) 2026 {AUTHORS}\n\n"
)
.context("failed to write program banner")
}
fn write_scan_summary(probes: usize, interface: &str, source: Ipv4Addr) -> anyhow::Result<()> {
let stderr = io::stderr();
let mut output = stderr.lock();
write_scan_summary_to(&mut output, probes, interface, source)?;
output.flush().context("failed to flush output stream")
}
fn write_scan_summary_to(
output: &mut impl Write,
probes: usize,
interface: &str,
source: Ipv4Addr,
) -> anyhow::Result<()> {
writeln!(
output,
"Scanning: {probes} host/port pairs via {interface} ({source})..."
)
.context("failed to write scan summary")
}
fn write_done_summary(probes: usize, elapsed: f64) -> anyhow::Result<()> {
let stderr = io::stderr();
let mut output = stderr.lock();
write_done_summary_to(&mut output, probes, elapsed)
}
fn write_done_summary_to(
output: &mut impl Write,
probes: usize,
elapsed: f64,
) -> anyhow::Result<()> {
writeln!(
output,
"\nDone: {probes} host/port pairs scanned in {elapsed:.1} seconds"
)
.context("failed to write scan completion")
}
fn write_incomplete_summary(probes_sent: usize, total_probes: usize) -> anyhow::Result<()> {
let stderr = io::stderr();
let mut output = stderr.lock();
write_incomplete_summary_to(&mut output, probes_sent, total_probes)?;
output.flush().context("failed to flush output stream")
}
fn write_incomplete_summary_to(
output: &mut impl Write,
probes_sent: usize,
total_probes: usize,
) -> anyhow::Result<()> {
writeln!(
output,
"\nIncomplete: sent {probes_sent} of {total_probes} host/port pairs"
)
.context("failed to write incomplete scan summary")
}
fn write_results(results: &[ScanResult]) -> anyhow::Result<()> {
let stdout = io::stdout();
let mut output = stdout.lock();
write_results_to(&mut output, results)
}
fn write_results_to(output: &mut impl Write, results: &[ScanResult]) -> anyhow::Result<()> {
if !results.is_empty() {
writeln!(output, "\nScan results:").context("failed to write results heading")?;
}
for &result in results {
write_result_to(output, result, false)?;
}
Ok(())
}
fn write_verbose_result(result: ScanResult) -> anyhow::Result<()> {
let stdout = io::stdout();
let mut output = stdout.lock();
write_result_to(&mut output, result, true)?;
output.flush().context("failed to flush output stream")
}
fn write_result_to(
output: &mut impl Write,
result: ScanResult,
verbose: bool,
) -> anyhow::Result<()> {
let state = match result.state {
PortState::Open => "open",
PortState::Closed => "closed",
_ => "unknown",
};
let prefix = if verbose { "[verbose] " } else { "" };
writeln!(output, "{prefix}{state} {}:{}", result.host, result.port)
.context("failed to write scan result")
}
fn write_progress(progress: ScanProgress) -> anyhow::Result<()> {
let line = format_progress(progress, Local::now());
let stderr = io::stderr();
let mut output = stderr.lock();
writeln!(output, "{line}").context("failed to write scan progress")?;
output.flush().context("failed to flush output stream")
}
fn format_progress<Tz>(progress: ScanProgress, now: DateTime<Tz>) -> String
where
Tz: TimeZone,
Tz::Offset: fmt::Display,
{
let eta = progress
.estimated_remaining()
.and_then(|remaining| chrono::Duration::from_std(remaining).ok())
.and_then(|remaining| now.checked_add_signed(remaining))
.map_or_else(
|| "unknown".to_owned(),
|eta| eta.format("%a %Y-%m-%d %H:%M:%S %Z").to_string(),
);
format!("[stats] {}% done | ETA {eta}", progress.percent())
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn, reason = "panics are allowed in test code")]
#[expect(clippy::unwrap_used, reason = "tests can use `unwrap`")]
mod tests {
use clap::error::ErrorKind;
use super::*;
#[test]
fn parses_default_options() -> anyhow::Result<()> {
let arguments =
Arguments::try_parse_from(["zucchini", "--host", "127.0.0.1", "--interface", "lo"])?;
assert_eq!(arguments.host, Targets(vec!["127.0.0.1".parse()?]));
assert_eq!(arguments.interface, "lo");
assert_eq!(arguments.bandwidth, 15);
assert!(arguments.ports.is_none());
assert!(!arguments.closed);
assert_eq!(arguments.timeout, 30);
assert!(!arguments.verbose);
Ok(())
}
#[test]
fn parses_all_scanner_options() -> anyhow::Result<()> {
let arguments = Arguments::try_parse_from([
"zucchini",
"--host",
"192.168.2.0/24",
"--interface",
"eth0",
"--bandwidth",
"100",
"--ports",
"22,80",
"--closed",
"--timeout",
"60",
"--verbose",
])?;
assert_eq!(arguments.host, Targets(parse_targets("192.168.2.0/24")?));
assert_eq!(arguments.interface, "eth0");
assert_eq!(arguments.bandwidth, 100);
assert_eq!(arguments.ports, Some(Ports(vec![22, 80])));
assert!(arguments.closed);
assert_eq!(arguments.timeout, 60);
assert!(arguments.verbose);
Ok(())
}
#[test]
fn rejects_missing_unknown_and_invalid_options() {
Arguments::try_parse_from(["zucchini", "-i", "lo"]).unwrap_err();
Arguments::try_parse_from(["zucchini", "-h", "127.0.0.1"]).unwrap_err();
Arguments::try_parse_from(["zucchini", "-h", "127.0.0.1", "-i", "lo", "--unknown"])
.unwrap_err();
Arguments::try_parse_from(["zucchini", "-h", "127.0.0.1", "-i", "lo", "--timeout", "0"])
.unwrap_err();
Arguments::try_parse_from([
"zucchini",
"-h",
"127.0.0.1",
"-i",
"lo",
"--bandwidth",
"0",
])
.unwrap_err();
Arguments::try_parse_from(["zucchini", "-h", "not-an-address", "-i", "lo"]).unwrap_err();
Arguments::try_parse_from([
"zucchini",
"-h",
"127.0.0.1",
"-i",
"lo",
"--ports",
"80-79",
])
.unwrap_err();
}
#[test]
fn formats_banner_scan_and_completion_summaries() -> anyhow::Result<()> {
let mut output = Vec::new();
write_banner_to(&mut output)?;
write_scan_summary_to(&mut output, 3, "eth0", "192.168.2.1".parse()?)?;
write_done_summary_to(&mut output, 3, 30.14)?;
assert_eq!(
String::from_utf8(output)?,
format!(
"{PROGRAM} {VERSION} - {DESCRIPTION}\nCopyright (c) 2026 {AUTHORS}\n\n\
Scanning: 3 host/port pairs via eth0 (192.168.2.1)...\n\n\
Done: 3 host/port pairs scanned in 30.1 seconds\n"
)
);
Ok(())
}
#[test]
fn formats_incomplete_summary() -> anyhow::Result<()> {
let mut output = Vec::new();
write_incomplete_summary_to(&mut output, 1, 3)?;
assert_eq!(
String::from_utf8(output)?,
"\nIncomplete: sent 1 of 3 host/port pairs\n"
);
Ok(())
}
#[test]
fn formats_empty_buffered_and_verbose_results() -> anyhow::Result<()> {
let open = ScanResult::new("172.16.100.2".parse()?, 443, PortState::Open);
let closed = ScanResult::new("172.16.100.3".parse()?, 80, PortState::Closed);
let mut output = Vec::new();
write_results_to(&mut output, &[])?;
assert!(output.is_empty());
write_results_to(&mut output, &[open, closed])?;
assert_eq!(
String::from_utf8(output)?,
concat!(
"\n",
"Scan results:\n",
"open 172.16.100.2:443\n",
"closed 172.16.100.3:80\n",
)
);
let mut verbose = Vec::new();
write_result_to(&mut verbose, open, true)?;
assert_eq!(
String::from_utf8(verbose)?,
"[verbose] open 172.16.100.2:443\n"
);
Ok(())
}
#[test]
fn formats_progress_with_fixed_time() {
let now = chrono::Utc
.with_ymd_and_hms(2026, 1, 1, 12, 0, 0)
.single()
.unwrap();
let progress = ScanProgress::new(25, 100, Duration::from_secs(60));
let not_started = ScanProgress::new(0, 100, Duration::from_secs(60));
let complete = ScanProgress::new(100, 100, Duration::from_secs(60));
assert_eq!(
format_progress(progress, now),
"[stats] 25% done | ETA Thu 2026-01-01 12:03:00 UTC"
);
assert_eq!(
format_progress(not_started, now),
"[stats] 0% done | ETA unknown"
);
assert_eq!(
format_progress(complete, now),
"[stats] 100% done | ETA Thu 2026-01-01 12:00:00 UTC"
);
}
#[test]
fn formats_progress_with_non_utc_offset() {
let offset = chrono::FixedOffset::east_opt(3600).unwrap();
let now = offset
.with_ymd_and_hms(2026, 1, 1, 12, 0, 0)
.single()
.unwrap();
let progress = ScanProgress::new(25, 100, Duration::from_secs(60));
assert_eq!(
format_progress(progress, now),
"[stats] 25% done | ETA Thu 2026-01-01 12:03:00 +01:00"
);
}
#[test]
fn help_flag_displays_help() {
let error = Arguments::try_parse_from(["zucchini", "--help"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::DisplayHelp);
}
#[test]
fn version_flag_is_unknown() {
let error =
Arguments::try_parse_from(["zucchini", "-h", "127.0.0.1", "-i", "lo", "--version"])
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::UnknownArgument);
}
}