diff --git a/Cargo.lock b/Cargo.lock index a60838cd4..cc75a7ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1794,6 +1794,7 @@ dependencies = [ "rlimit", "serde", "serde_derive", + "serde_json", "text_placeholder", "toml", "wait-timeout", diff --git a/Cargo.toml b/Cargo.toml index b59bf10ae..dcf852c32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ once_cell = "1.21.4" parameterized = "2.0.0" wait-timeout = "0.2" criterion = { version = "0.8", features = ["html_reports"] } +serde_json = "1.0" [package.metadata.deb] depends = "$auto, nmap" diff --git a/examples/default_silent.rs b/examples/default_silent.rs new file mode 100644 index 000000000..e96295b5f --- /dev/null +++ b/examples/default_silent.rs @@ -0,0 +1,10 @@ +/// Example that verifies the library is silent by default. +/// Does NOT call enable_output() β€” all output macros should produce +/// nothing on stdout. Built and spawned by the integration test in +/// tests/stdout_suppression.rs. +fn main() { + // Do NOT call enable_output() β€” library must be silent by default. + rustscan::warning!("DEFAULT_SILENT_WARNING"); + rustscan::detail!("DEFAULT_SILENT_DETAIL"); + rustscan::output!("DEFAULT_SILENT_OUTPUT"); +} diff --git a/examples/stdout_check.rs b/examples/stdout_check.rs new file mode 100644 index 000000000..f724458e9 --- /dev/null +++ b/examples/stdout_check.rs @@ -0,0 +1,19 @@ +/// Example that exercises output macros with and without suppress_output(). +/// Built and spawned by the integration test in tests/stdout_suppression.rs. +fn main() { + // Enable output first (it is suppressed by default for library consumers). + rustscan::enable_output(); + + // Before suppression: these markers should appear in stdout. + rustscan::warning!("PRE_SUPPRESS_WARNING"); + rustscan::detail!("PRE_SUPPRESS_DETAIL"); + rustscan::output!("PRE_SUPPRESS_OUTPUT"); + + // Suppress all further library output. + rustscan::suppress_output(); + + // After suppression: these markers must NOT appear in stdout. + rustscan::warning!("POST_SUPPRESS_WARNING"); + rustscan::detail!("POST_SUPPRESS_DETAIL"); + rustscan::output!("POST_SUPPRESS_OUTPUT"); +} diff --git a/src/input.rs b/src/input.rs index 8a08845af..e0f499a8c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -312,7 +312,9 @@ impl Config { let config: Config = match toml::from_str(&content) { Ok(config) => config, Err(e) => { - println!("Found {e} in configuration file.\nAborting scan.\n"); + if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!("Found {e} in configuration file.\nAborting scan.\n"); + } std::process::exit(1); } }; diff --git a/src/lib.rs b/src/lib.rs index e335de58a..1e3f77bee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,44 @@ //! ``` #![allow(clippy::needless_doctest_main)] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// When `true`, all stdout output from the library is suppressed. +/// Defaults to `true` so that library consumers get silent output. +/// The CLI entry point (`main.rs`) calls [`enable_output()`] at startup +/// to restore normal terminal printing. +#[doc(hidden)] +pub static SUPPRESS_STDOUT: AtomicBool = AtomicBool::new(true); + +/// Suppress all stdout output from the library. +/// +/// Output is suppressed by default. Call this only after calling +/// [`enable_output()`] if you need to re-silence output mid-session. +/// Has no effect on `log` crate logging (controlled by `RUST_LOG`). +/// +/// ```ignore +/// rustscan::enable_output(); +/// // ... print something ... +/// rustscan::suppress_output(); +/// // ... output is silent again +/// ``` +pub fn suppress_output() { + SUPPRESS_STDOUT.store(true, Ordering::Relaxed); +} + +/// Enable stdout output from the library. +/// +/// Output is suppressed by default. The CLI calls this at startup; +/// library consumers should call this only if they want terminal output. +/// +/// ```ignore +/// rustscan::enable_output(); +/// // ... output will now print to stdout +/// ``` +pub fn enable_output() { + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); +} + pub mod tui; pub mod input; @@ -56,3 +94,43 @@ pub mod scripts; pub mod address; pub mod generated; + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + + /// Sequential test covering all toggle behaviours on the shared + /// `SUPPRESS_STDOUT` static. Individual `#[test]` functions would + /// race under parallel test execution; a single test runs them in + /// the order shown and is safe with any `--test-threads` setting. + /// + /// The actual stdout-suppression effect is verified by the + /// integration tests in tests/stdout_suppression.rs, which spawn + /// subprocesses and therefore have no shared state. + #[test] + fn toggle_flag_behavior() { + // Save original value to avoid contaminating parallel unit tests + // that share the process-global AtomicBool. + let original = SUPPRESS_STDOUT.load(Ordering::Relaxed); + + // --- suppress_output sets the flag --- + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); + suppress_output(); + assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); + + // --- suppress_output is idempotent --- + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); + suppress_output(); + suppress_output(); + assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); + + // --- enable_output clears the flag --- + SUPPRESS_STDOUT.store(true, Ordering::Relaxed); + enable_output(); + assert!(!SUPPRESS_STDOUT.load(Ordering::Relaxed)); + + // Restore original value so parallel tests see the expected state. + SUPPRESS_STDOUT.store(original, Ordering::Relaxed); + } +} diff --git a/src/main.rs b/src/main.rs index 08e6eaf80..8bd83bd6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,9 @@ extern crate log; /// Faster Nmap scanning with Rust /// If you're looking for the actual scanning, check out the module Scanner fn main() { + // Enable stdout output for CLI usage (suppressed by default for library consumers). + rustscan::enable_output(); + #[cfg(not(unix))] let _ = ansi_term::enable_ansi_support(); diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 760783479..136f374c6 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -288,7 +288,9 @@ impl Scanner { } } Err(e) => { - println!("Err E binding sock {e:?}"); + if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!("Err E binding sock {e:?}"); + } Err(e) } } @@ -296,7 +298,7 @@ impl Scanner { /// Formats and prints the port status fn fmt_ports(&self, socket: SocketAddr) { - if !self.greppable { + if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) && !self.greppable { if self.accessible { println!("Open {socket}"); } else { diff --git a/src/tui.rs b/src/tui.rs index 66810cc88..b80acd1e7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,11 +5,13 @@ #[macro_export] macro_rules! warning { ($name:expr) => { - println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name); + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name); + } }; ($name:expr, $greppable:expr, $accessible:expr) => { // if not greppable then print, otherwise no else statement so do not print. - if !$greppable { + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) && !$greppable { if $accessible { // Don't print the ascii art println!("{}", $name); @@ -23,11 +25,13 @@ macro_rules! warning { #[macro_export] macro_rules! detail { ($name:expr) => { - println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name); + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name); + } }; ($name:expr, $greppable:expr, $accessible:expr) => { // if not greppable then print, otherwise no else statement so do not print. - if !$greppable { + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) && !$greppable { if $accessible { // Don't print the ascii art println!("{}", $name); @@ -41,15 +45,17 @@ macro_rules! detail { #[macro_export] macro_rules! output { ($name:expr) => { - println!( - "{} {}", - ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"), - $name - ); + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!( + "{} {}", + ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"), + $name + ); + } }; ($name:expr, $greppable:expr, $accessible:expr) => { // if not greppable then print, otherwise no else statement so do not print. - if !$greppable { + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) && !$greppable { if $accessible { // Don't print the ascii art println!("{}", $name); @@ -68,39 +74,41 @@ macro_rules! output { macro_rules! funny_opening { // prints a funny quote / opening () => { - use rand::seq::IndexedRandom; - let quotes = vec![ - "Nmap? More like slowmap.🐒", - "🌍HACK THE PLANET🌍", - "Real hackers hack time βŒ›", - "Please contribute more quotes to our GitHub https://github.com/rustscan/rustscan", - "😡 https://admin.tryhackme.com", - "0day was here β™₯", - "I don't always scan ports, but when I do, I prefer RustScan.", - "RustScan: Where scanning meets swagging. 😎", - "To scan or not to scan? That is the question.", - "RustScan: Because guessing isn't hacking.", - "Scanning ports like it's my full-time job. Wait, it is.", - "Open ports, closed hearts.", - "I scanned my computer so many times, it thinks we're dating.", - "Port scanning: Making networking exciting since... whenever.", - "You miss 100% of the ports you don't scan. - RustScan", - "Breaking and entering... into the world of open ports.", - "TCP handshake? More like a friendly high-five!", - "Scanning ports: The virtual equivalent of knocking on doors.", - "RustScan: Making sure 'closed' isn't just a state of mind.", - "RustScan: allowing you to send UDP packets into the void 1200x faster than NMAP", - "Port scanning: Because every port has a story to tell.", - "I scanned ports so fast, even my computer was surprised.", - "Scanning ports faster than you can say 'SYN ACK'", - "RustScan: Where '404 Not Found' meets '200 OK'.", - "RustScan: Exploring the digital landscape, one IP at a time.", - "TreadStone was here πŸš€", - "With RustScan, I scan ports so fast, even my firewall gets whiplash πŸ’¨", - "Scanning ports so fast, even the internet got a speeding ticket!", - ]; - let random_quote = quotes.choose(&mut rand::rng()).unwrap(); + if !$crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + use rand::seq::IndexedRandom; + let quotes = vec![ + "Nmap? More like slowmap.🐒", + "🌍HACK THE PLANET🌍", + "Real hackers hack time βŒ›", + "Please contribute more quotes to our GitHub https://github.com/rustscan/rustscan", + "😡 https://admin.tryhackme.com", + "0day was here β™₯", + "I don't always scan ports, but when I do, I prefer RustScan.", + "RustScan: Where scanning meets swagging. 😎", + "To scan or not to scan? That is the question.", + "RustScan: Because guessing isn't hacking.", + "Scanning ports like it's my full-time job. Wait, it is.", + "Open ports, closed hearts.", + "I scanned my computer so many times, it thinks we're dating.", + "Port scanning: Making networking exciting since... whenever.", + "You miss 100% of the ports you don't scan. - RustScan", + "Breaking and entering... into the world of open ports.", + "TCP handshake? More like a friendly high-five!", + "Scanning ports: The virtual equivalent of knocking on doors.", + "RustScan: Making sure 'closed' isn't just a state of mind.", + "RustScan: allowing you to send UDP packets into the void 1200x faster than NMAP", + "Port scanning: Because every port has a story to tell.", + "I scanned ports so fast, even my computer was surprised.", + "Scanning ports faster than you can say 'SYN ACK'", + "RustScan: Where '404 Not Found' meets '200 OK'.", + "RustScan: Exploring the digital landscape, one IP at a time.", + "TreadStone was here πŸš€", + "With RustScan, I scan ports so fast, even my firewall gets whiplash πŸ’¨", + "Scanning ports so fast, even the internet got a speeding ticket!", + ]; + let random_quote = quotes.choose(&mut rand::rng()).unwrap(); - println!("{}\n", random_quote); + println!("{}\n", random_quote); + } }; } diff --git a/tests/stdout_suppression.rs b/tests/stdout_suppression.rs new file mode 100644 index 000000000..741dc9ea8 --- /dev/null +++ b/tests/stdout_suppression.rs @@ -0,0 +1,158 @@ +//! Integration test: verify that suppress_output() actually silences +//! stdout produced by the library's output macros (`warning!`, `detail!`, +//! `output!`). The test builds and runs the `stdout_check` example as a +//! subprocess so we can capture real stdout rather than just checking the +//! internal AtomicBool flag. + +use std::path::PathBuf; +use std::process::Command; + +/// Build an example and return the path to the compiled binary. +/// +/// Uses `cargo build --message-format=json` so that the profile matches +/// the test binary (`cfg!(debug_assertions)`) and the returned path +/// respects `CARGO_TARGET_DIR` and workspace layouts. +fn build_example(name: &str) -> PathBuf { + let mut args: Vec<&str> = vec!["build", "--example", name, "--message-format=json"]; + if !cfg!(debug_assertions) { + args.push("--release"); + } + + let output = Command::new("cargo") + .args(&args) + .output() + .expect("failed to run cargo build"); + + assert!( + output.status.success(), + "cargo build --example {} failed\nstderr:\n{}", + name, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let value: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, + }; + + if value.get("reason").and_then(|v| v.as_str()) != Some("compiler-artifact") { + continue; + } + + if value + .get("target") + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()) + != Some(name) + { + continue; + } + + if let Some(executable) = value.get("executable").and_then(|v| v.as_str()) { + let path = PathBuf::from(executable); + assert!( + path.exists(), + "cargo-reported binary does not exist: {}", + path.display() + ); + return path; + } + } + + panic!( + "could not find executable for example '{}' in cargo build output", + name + ); +} + +#[test] +fn suppress_output_actually_silences_macro_output() { + let bin = build_example("stdout_check"); + + let output = Command::new(&bin) + .output() + .expect("failed to spawn stdout_check example"); + + assert!( + output.status.success(), + "example exited with {}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Pre-suppression markers must be present. + assert!( + stdout.contains("PRE_SUPPRESS_WARNING"), + "PRE_SUPPRESS_WARNING missing from stdout:\n{}", + stdout + ); + assert!( + stdout.contains("PRE_SUPPRESS_DETAIL"), + "PRE_SUPPRESS_DETAIL missing from stdout:\n{}", + stdout + ); + assert!( + stdout.contains("PRE_SUPPRESS_OUTPUT"), + "PRE_SUPPRESS_OUTPUT missing from stdout:\n{}", + stdout + ); + + // Post-suppression markers must be absent. + assert!( + !stdout.contains("POST_SUPPRESS_WARNING"), + "POST_SUPPRESS_WARNING leaked into stdout:\n{}", + stdout + ); + assert!( + !stdout.contains("POST_SUPPRESS_DETAIL"), + "POST_SUPPRESS_DETAIL leaked into stdout:\n{}", + stdout + ); + assert!( + !stdout.contains("POST_SUPPRESS_OUTPUT"), + "POST_SUPPRESS_OUTPUT leaked into stdout:\n{}", + stdout + ); +} + +/// Fresh-process test: verify the library is silent by default. +/// The `default_silent` example does NOT call `enable_output()`, +/// so all macro output should be suppressed by the initialiser. +#[test] +fn default_silent_output_is_suppressed() { + let bin = build_example("default_silent"); + + let output = Command::new(&bin) + .output() + .expect("failed to spawn default_silent example"); + + assert!( + output.status.success(), + "example exited with {}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Default-silent markers must NOT appear β€” library is silent by default. + assert!( + !stdout.contains("DEFAULT_SILENT_WARNING"), + "DEFAULT_SILENT_WARNING leaked into stdout (library should be silent by default):\n{}", + stdout + ); + assert!( + !stdout.contains("DEFAULT_SILENT_DETAIL"), + "DEFAULT_SILENT_DETAIL leaked into stdout:\n{}", + stdout + ); + assert!( + !stdout.contains("DEFAULT_SILENT_OUTPUT"), + "DEFAULT_SILENT_OUTPUT leaked into stdout:\n{}", + stdout + ); +}