diff --git a/src/input.rs b/src/input.rs index 8a08845af..318cfaba3 100644 --- a/src/input.rs +++ b/src/input.rs @@ -124,7 +124,8 @@ pub struct Opts { #[arg(long, default_value = "1")] pub tries: u8, - /// Automatically ups the ULIMIT with the value you provided. + /// Automatically increases the Unix file-descriptor limit. + #[cfg_attr(not(unix), arg(hide = true))] #[arg(short, long)] pub ulimit: Option, @@ -178,6 +179,27 @@ impl Opts { opts } + /// Validates options whose availability or semantics depend on the + /// operating system. + /// + /// # Errors + /// + /// Returns an error when an option is unsupported on the current platform. + pub fn validate_platform(&self) -> Result<(), String> { + #[cfg(not(unix))] + { + if self.ulimit.is_some() { + return Err( + "--ulimit is only supported on Unix-like operating systems. \ + On Windows, use --batch-size (-b) to control scan concurrency." + .to_owned(), + ); + } + } + + Ok(()) + } + /// Reads the command line arguments into an Opts struct and merge /// values found within the user configuration file. pub fn merge(&mut self, config: &Config) { @@ -395,6 +417,82 @@ mod tests { assert_eq!(command, opts.command); } + #[test] + fn parses_explicit_batch_size() { + let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "-b", "1234"]); + + assert_eq!(opts.batch_size, 1234); + } + + #[test] + fn parses_explicit_long_batch_size() { + let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "4321"]); + + assert_eq!(opts.batch_size, 4321); + } + + #[test] + #[cfg(windows)] + fn windows_platform_validation_accepts_batch_size() { + let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "500"]); + + assert!(opts.validate_platform().is_ok()); + assert_eq!(opts.batch_size, 500); + } + + #[test] + #[cfg(windows)] + fn windows_rejects_ulimit() { + let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]); + + let error = opts + .validate_platform() + .expect_err("Windows must reject --ulimit"); + + assert!(error.contains("--ulimit")); + assert!(error.contains("Unix")); + assert!(error.contains("--batch-size")); + } + + #[test] + #[cfg(windows)] + fn windows_hides_ulimit_from_help() { + let help = Opts::command().render_long_help().to_string(); + + assert!( + !help.contains("--ulimit"), + "--ulimit should not be advertised on Windows" + ); + } + + #[test] + #[cfg(windows)] + fn windows_rejects_ulimit_from_config_merge() { + let mut opts = Opts { + no_config: false, + ..Default::default() + }; + let mut config = Config::default(); + config.ulimit = Some(5_000); + + opts.merge(&config); + + let error = opts + .validate_platform() + .expect_err("Windows must reject --ulimit supplied by configuration"); + + assert!(error.contains("--ulimit")); + } + + #[test] + #[cfg(unix)] + fn unix_platform_validation_accepts_ulimit() { + let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]); + + assert!(opts.validate_platform().is_ok()); + assert_eq!(opts.ulimit, Some(5_000)); + } + #[test] fn opts_no_merge_when_config_is_ignored() { let mut opts = Opts::default(); diff --git a/src/main.rs b/src/main.rs index 08e6eaf80..988934624 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ extern crate dirs; #[cfg(unix)] const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000; // Safest batch size based on experimentation +#[cfg(unix)] const AVERAGE_BATCH_SIZE: usize = 3000; #[macro_use] @@ -46,6 +47,11 @@ fn main() { let config = Config::read(opts.config_path.clone()); opts.merge(&config); + if let Err(message) = opts.validate_platform() { + eprintln!("error: {message}"); + std::process::exit(2); + } + debug!("Main() `opts` arguments are {opts:?}"); let scripts_to_run: Vec = match init_scripts(&opts.scripts) { @@ -77,11 +83,8 @@ fn main() { std::process::exit(1); } - #[cfg(unix)] - let batch_size: usize = infer_batch_size(&opts, adjust_ulimit_size(&opts)); - - #[cfg(not(unix))] - let batch_size: usize = AVERAGE_BATCH_SIZE; + let batch_size = effective_batch_size(&opts); + debug!("Effective batch size: {batch_size}"); let scanner = Scanner::new( &ips, @@ -122,7 +125,7 @@ fn main() { \n*I used {} batch size, consider lowering it with {} or a comfortable number for your system. \n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n", ip, - opts.batch_size, + batch_size, "'rustscan -b -a '"); warning!(x, opts.greppable, opts.accessible); } @@ -191,6 +194,23 @@ fn main() { info!("{}", benchmarks.summary()); } +/// Determines the actual batch size used by the scanner. +/// +/// Unix systems may reduce the requested batch size according to the process +/// file-descriptor limit. Other platforms, including Windows, use the batch +/// size explicitly requested by the user. +fn effective_batch_size(opts: &Opts) -> usize { + #[cfg(unix)] + { + infer_batch_size(opts, adjust_ulimit_size(opts)) + } + + #[cfg(not(unix))] + { + opts.batch_size + } +} + /// Prints the opening title of RustScan #[allow(clippy::items_after_statements, clippy::needless_raw_string_hashes)] fn print_opening(opts: &Opts) { @@ -299,7 +319,7 @@ fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize { mod tests { #[cfg(unix)] use super::{adjust_ulimit_size, infer_batch_size}; - use super::{print_opening, Opts}; + use super::{effective_batch_size, print_opening, Opts}; #[test] #[cfg(unix)] @@ -367,11 +387,41 @@ mod tests { #[test] fn test_print_opening_no_panic() { + let opts = Opts::default(); + // print opening should not panic + print_opening(&opts); + } + + #[test] + #[cfg(windows)] + fn windows_batch_size_uses_requested_value() { let opts = Opts { - ulimit: Some(2_000), + batch_size: 50, ..Default::default() }; - // print opening should not panic - print_opening(&opts); + + assert_eq!(effective_batch_size(&opts), 50); + } + + #[test] + #[cfg(windows)] + fn windows_batch_size_preserves_large_requested_value() { + let opts = Opts { + batch_size: 12_345, + ..Default::default() + }; + + assert_eq!(effective_batch_size(&opts), 12_345); + } + + #[test] + #[cfg(windows)] + fn windows_batch_size_preserves_single_connection() { + let opts = Opts { + batch_size: 1, + ..Default::default() + }; + + assert_eq!(effective_batch_size(&opts), 1); } } diff --git a/tests/windows_correctness.rs b/tests/windows_correctness.rs new file mode 100644 index 000000000..39246147b --- /dev/null +++ b/tests/windows_correctness.rs @@ -0,0 +1,208 @@ +#![cfg(windows)] + +//! Native Windows integration tests for RustScan CLI correctness. +//! +//! These tests intentionally execute the compiled `rustscan.exe` rather than +//! calling internal functions. They verify the complete path through Clap +//! parsing, configuration merging, platform validation, and scanner startup. + +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn rustscan_executable() -> &'static str { + env!("CARGO_BIN_EXE_rustscan") +} + +fn unique_temp_path(name: &str) -> PathBuf { + let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + + std::env::temp_dir().join(format!( + "rustscan-windows-correctness-{}-{counter}-{name}", + std::process::id() + )) +} + +fn nonexistent_config_path() -> PathBuf { + let path = unique_temp_path("missing-config.toml"); + + // The path should normally already be absent. Removing it here makes + // the invariant explicit if a stale file from an interrupted run exists. + let _ = fs::remove_file(&path); + + path +} + +fn run_rustscan(args: &[&str], enable_debug_logging: bool) -> Output { + let mut command = Command::new(rustscan_executable()); + command.args(args); + + if enable_debug_logging { + command.env("RUST_LOG", "debug"); + } + + command + .output() + .expect("failed to execute the compiled rustscan binary") +} + +fn combined_output(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +#[test] +fn windows_help_hides_ulimit_and_keeps_batch_size_visible() { + let output = run_rustscan(&["--help"], false); + + assert!( + output.status.success(), + "`rustscan --help` failed:\n{}", + combined_output(&output) + ); + + let text = combined_output(&output); + + assert!( + text.contains("--batch-size"), + "Windows help should advertise --batch-size:\n{}", + text + ); + assert!( + !text.contains("--ulimit"), + "Windows help should not advertise Unix-only --ulimit:\n{}", + text + ); +} + +#[test] +fn windows_rejects_ulimit_from_command_line() { + let config_path = nonexistent_config_path(); + let config_path = config_path.to_string_lossy().into_owned(); + + let output = run_rustscan( + &[ + "--no-config", + "--config-path", + &config_path, + "--addresses", + "127.0.0.1", + "--ulimit", + "5000", + "--scripts", + "none", + "--greppable", + ], + false, + ); + + assert_eq!( + output.status.code(), + Some(2), + "Windows --ulimit should exit with code 2:\n{}", + combined_output(&output) + ); + + let text = combined_output(&output); + + assert!( + text.contains("--ulimit is only supported on Unix-like operating systems"), + "expected explicit Windows platform-validation error:\n{}", + text + ); + assert!( + text.contains("--batch-size"), + "error should direct Windows users to --batch-size:\n{}", + text + ); +} + +#[test] +fn windows_rejects_ulimit_from_config_after_merge() { + let config_path = unique_temp_path("ulimit-config.toml"); + + fs::write(&config_path, "ulimit = 5000\n") + .expect("failed to create temporary RustScan configuration"); + + let config_path_string = config_path.to_string_lossy().into_owned(); + + let output = run_rustscan( + &[ + "--config-path", + &config_path_string, + "--addresses", + "127.0.0.1", + "--scripts", + "none", + "--greppable", + ], + false, + ); + + let _ = fs::remove_file(&config_path); + + assert_eq!( + output.status.code(), + Some(2), + "Windows ulimit supplied by config should exit with code 2:\n{}", + combined_output(&output) + ); + + let text = combined_output(&output); + + assert!( + text.contains("--ulimit is only supported on Unix-like operating systems"), + "ulimit merged from config should be rejected on Windows:\n{}", + text + ); +} + +#[test] +fn windows_explicit_batch_size_reaches_scanner_startup() { + let config_path = nonexistent_config_path(); + let config_path = config_path.to_string_lossy().into_owned(); + + // Scan a single localhost port to keep the test deterministic and fast + // while still exercising the real Scanner construction path. + let output = run_rustscan( + &[ + "--no-config", + "--config-path", + &config_path, + "--addresses", + "127.0.0.1", + "--ports", + "1", + "--batch-size", + "17", + "--timeout", + "50", + "--tries", + "1", + "--scripts", + "none", + "--greppable", + ], + true, + ); + + assert!( + output.status.success(), + "RustScan failed while exercising explicit Windows batch size:\n{}", + combined_output(&output) + ); + + let text = combined_output(&output); + + assert!( + text.contains("Effective batch size: 17"), + "the requested Windows batch size did not reach scanner startup:\n{}", + text + ); +}