From e9dcdbdd2b736aadf4541d70a07eed38ca376b26 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 02:11:02 +0800 Subject: [PATCH 01/11] fix: suppress stdout in library mode via suppress_output() toggle Add a global AtomicBool flag SUPPRESS_STDOUT (default false for CLI compatibility) and a public suppress_output() function. Single-argument forms of the warning!, detail!, output!, and funny_opening! macros now check this flag before printing. Also fix a bare println! in the UDP scanner error path that bypassed the greppable check. Closes bee-san/RustScan#706 Signed-off-by: Yuchen Fan --- src/lib.rs | 22 ++++++++++++ src/scanner/mod.rs | 4 ++- src/tui.rs | 88 +++++++++++++++++++++++++--------------------- 3 files changed, 73 insertions(+), 41 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e335de58a..70df08ef8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,28 @@ //! ``` #![allow(clippy::needless_doctest_main)] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// When `true`, all stdout output from the library is suppressed. +/// The CLI entry point (`main.rs`) never sets this, so it prints normally. +/// Library consumers should call [`suppress_output()`] to silence output. +#[doc(hidden)] +pub static SUPPRESS_STDOUT: AtomicBool = AtomicBool::new(false); + +/// Suppress all stdout output from the library. +/// +/// Call this once before using the scanner when using RustScan as a library +/// dependency to prevent noisy terminal output. Has no effect on `log` +/// crate logging (which is controlled by `RUST_LOG`). +/// +/// ```ignore +/// rustscan::suppress_output(); +/// // ... construct and run the scanner normally +/// ``` +pub fn suppress_output() { + SUPPRESS_STDOUT.store(true, Ordering::Relaxed); +} + pub mod tui; pub mod input; diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 760783479..368a46fb2 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) { + eprintln!("Err E binding sock {e:?}"); + } Err(e) } } diff --git a/src/tui.rs b/src/tui.rs index 66810cc88..c35e19c37 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,7 +5,9 @@ #[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. @@ -23,7 +25,9 @@ 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. @@ -41,11 +45,13 @@ 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. @@ -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); + } }; } From ebe4a6de857add228df5b4c3ebe2436b88fbcbf5 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 02:14:13 +0800 Subject: [PATCH 02/11] fix: guard fmt_ports and 3-arg macros with SUPPRESS_STDOUT fmt_ports() in scanner/mod.rs now checks SUPPRESS_STDOUT before printing open port lines. warning!/detail!/output! 3-argument macro arms in tui.rs also gate on SUPPRESS_STDOUT so library callers using suppress_output() get true silence. Signed-off-by: Yuchen Fan --- src/scanner/mod.rs | 2 +- src/tui.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 368a46fb2..e798917da 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -298,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 c35e19c37..b80acd1e7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -11,7 +11,7 @@ macro_rules! warning { }; ($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); @@ -31,7 +31,7 @@ macro_rules! detail { }; ($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); @@ -55,7 +55,7 @@ macro_rules! output { }; ($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); From 3cc1fe651d3b5912242d7de83b76d0688afd3a53 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 02:22:36 +0800 Subject: [PATCH 03/11] fix: address isolation review findings for stdout suppression - src/input.rs: guard println! with SUPPRESS_STDOUT in Config::read() and replace std::process::exit(1) with panic! for library safety - src/scanner/mod.rs: revert undocumented eprintln! to println! at sock binding error site Signed-off-by: Functionhx <2994114386@qq.com> Signed-off-by: Yuchen Fan --- src/input.rs | 6 ++++-- src/scanner/mod.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/input.rs b/src/input.rs index 8a08845af..1bbbdb679 100644 --- a/src/input.rs +++ b/src/input.rs @@ -312,8 +312,10 @@ impl Config { let config: Config = match toml::from_str(&content) { Ok(config) => config, Err(e) => { - println!("Found {e} in configuration file.\nAborting scan.\n"); - std::process::exit(1); + if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { + println!("Found {e} in configuration file.\nAborting scan.\n"); + } + panic!("Found {} in configuration file.\nAborting scan.", e); } }; diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index e798917da..136f374c6 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -289,7 +289,7 @@ impl Scanner { } Err(e) => { if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { - eprintln!("Err E binding sock {e:?}"); + println!("Err E binding sock {e:?}"); } Err(e) } From dac2fae5c5ba31e709a2aad4c03fed533df95e34 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 02:36:36 +0800 Subject: [PATCH 04/11] fix: revert panic!() back to std::process::exit(1) in input.rs The prior change replaced a clean process exit with a panic, which: - Unwinds the full stack (exit(1) terminates immediately) - Prints a messy panic message + backtrace to stderr - Leaks error output to stderr even when SUPPRESS_STDOUT is true Restore the original exit(1) behavior, keeping only the conditional println! guard for stdout suppression. The scope of issue #706 is stdout noise, not error handling semantics. Signed-off-by: Yuchen Fan --- src/input.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input.rs b/src/input.rs index 1bbbdb679..e0f499a8c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -315,7 +315,7 @@ impl Config { if !crate::SUPPRESS_STDOUT.load(std::sync::atomic::Ordering::Relaxed) { println!("Found {e} in configuration file.\nAborting scan.\n"); } - panic!("Found {} in configuration file.\nAborting scan.", e); + std::process::exit(1); } }; From 8a5e00b5b72c9e04e181d8f1415d3c6f7ef79dbf Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 10:22:17 +0800 Subject: [PATCH 05/11] test: add regression tests for suppress_output() stdout suppression Verify suppress_output() flips SUPPRESS_STDOUT to true, defaults to false, and is idempotent across repeated calls. Signed-off-by: Yuchen Fan --- src/lib.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 70df08ef8..0b655173e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,3 +78,43 @@ pub mod scripts; pub mod address; pub mod generated; + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::Ordering; + + #[test] + fn suppress_output_defaults_to_false() { + // Ensure stdout is NOT suppressed by default (CLI mode). + // Reset atomics can carry state across tests, so explicitly store false. + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); + assert!( + !SUPPRESS_STDOUT.load(Ordering::Relaxed), + "SUPPRESS_STDOUT should default to false so CLI prints normally" + ); + } + + #[test] + fn suppress_output_disables_stdout() { + // Verify suppress_output() flips the guard to true. + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); + suppress_output(); + assert!( + SUPPRESS_STDOUT.load(Ordering::Relaxed), + "suppress_output() should set SUPPRESS_STDOUT to true" + ); + } + + #[test] + fn suppress_output_is_callable_multiple_times() { + // Calling suppress_output() repeatedly must remain idempotent. + SUPPRESS_STDOUT.store(false, Ordering::Relaxed); + suppress_output(); + suppress_output(); + assert!( + SUPPRESS_STDOUT.load(Ordering::Relaxed), + "repeated calls to suppress_output() must leave SUPPRESS_STDOUT true" + ); + } +} From 16f61db8104bc6fc9f75fd563efb3b7538b80279 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 11:45:18 +0800 Subject: [PATCH 06/11] test: add stdout capture integration test proving suppress_output() works Replace flawed unit tests (line 88 stored false before asserting, line 99 only checked AtomicBool) with a subprocess integration test that builds and runs the stdout_check example, capturing real stdout. Asserts pre-suppression markers appear and post-suppression markers do not. Signed-off-by: Yuchen Fan --- examples/stdout_check.rs | 16 ++++++ src/lib.rs | 30 +++-------- tests/stdout_suppression.rs | 104 ++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 examples/stdout_check.rs create mode 100644 tests/stdout_suppression.rs diff --git a/examples/stdout_check.rs b/examples/stdout_check.rs new file mode 100644 index 000000000..589665d41 --- /dev/null +++ b/examples/stdout_check.rs @@ -0,0 +1,16 @@ +/// Example that exercises output macros with and without suppress_output(). +/// Built and spawned by the integration test in tests/stdout_suppression.rs. +fn main() { + // 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/lib.rs b/src/lib.rs index 0b655173e..21d984950 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,37 +84,21 @@ mod tests { use super::*; use std::sync::atomic::Ordering; + /// Unit test: verify the flag mechanism toggles correctly. + /// The actual stdout suppression effect is verified by the + /// integration test in tests/stdout_suppression.rs. #[test] - fn suppress_output_defaults_to_false() { - // Ensure stdout is NOT suppressed by default (CLI mode). - // Reset atomics can carry state across tests, so explicitly store false. - SUPPRESS_STDOUT.store(false, Ordering::Relaxed); - assert!( - !SUPPRESS_STDOUT.load(Ordering::Relaxed), - "SUPPRESS_STDOUT should default to false so CLI prints normally" - ); - } - - #[test] - fn suppress_output_disables_stdout() { - // Verify suppress_output() flips the guard to true. + fn suppress_output_sets_flag() { SUPPRESS_STDOUT.store(false, Ordering::Relaxed); suppress_output(); - assert!( - SUPPRESS_STDOUT.load(Ordering::Relaxed), - "suppress_output() should set SUPPRESS_STDOUT to true" - ); + assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); } #[test] - fn suppress_output_is_callable_multiple_times() { - // Calling suppress_output() repeatedly must remain idempotent. + fn suppress_output_is_idempotent() { SUPPRESS_STDOUT.store(false, Ordering::Relaxed); suppress_output(); suppress_output(); - assert!( - SUPPRESS_STDOUT.load(Ordering::Relaxed), - "repeated calls to suppress_output() must leave SUPPRESS_STDOUT true" - ); + assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); } } diff --git a/tests/stdout_suppression.rs b/tests/stdout_suppression.rs new file mode 100644 index 000000000..f64b54e65 --- /dev/null +++ b/tests/stdout_suppression.rs @@ -0,0 +1,104 @@ +//! 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; + +/// Locate the compiled `stdout_check` example binary. +fn example_binary() -> PathBuf { + // Cargo sets CARGO_BIN_EXE_ for bin targets; examples may + // also be available under this key depending on the Cargo version. + if let Ok(p) = std::env::var("CARGO_BIN_EXE_stdout_check") { + let path = PathBuf::from(p); + if path.exists() { + return path; + } + } + + let manifest = + std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").into()); + let profile = if cfg!(debug_assertions) { + "debug" + } else { + "release" + }; + PathBuf::from(manifest) + .join("target") + .join(profile) + .join("examples") + .join("stdout_check") +} + +/// Build the example if it hasn't been built yet. +fn ensure_example_built() { + let status = Command::new("cargo") + .args(["build", "--example", "stdout_check"]) + .status() + .expect("failed to run cargo build --example stdout_check"); + assert!( + status.success(), + "cargo build --example stdout_check failed" + ); +} + +#[test] +fn suppress_output_actually_silences_macro_output() { + ensure_example_built(); + + let bin = example_binary(); + assert!( + bin.exists(), + "example binary not found at {}; run `cargo build --example stdout_check`", + bin.display() + ); + + 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 + ); +} From 5d779e0035fb1f5a5a7a0d6d7f3b91a3679cf1a7 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 12:06:51 +0800 Subject: [PATCH 07/11] fix: default to silent output for library use, CLI enables output explicitly Isolation review found that SUPPRESS_STDOUT defaulted to false, requiring library consumers to manually call suppress_output(). This does not fully resolve issue #706 because existing library usage remains noisy by default. Change SUPPRESS_STDOUT default to true (silent by default). Add enable_output() to allow library consumers and the CLI to explicitly enable terminal output. The CLI calls enable_output() at the start of main(). Integration tests updated accordingly. Fixes: #706 Signed-off-by: Yuchen Fan --- examples/stdout_check.rs | 3 +++ src/lib.rs | 44 +++++++++++++++++++++++++++++++++------- src/main.rs | 3 +++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/examples/stdout_check.rs b/examples/stdout_check.rs index 589665d41..f724458e9 100644 --- a/examples/stdout_check.rs +++ b/examples/stdout_check.rs @@ -1,6 +1,9 @@ /// 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"); diff --git a/src/lib.rs b/src/lib.rs index 21d984950..f5f6e9690 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,25 +44,41 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// When `true`, all stdout output from the library is suppressed. -/// The CLI entry point (`main.rs`) never sets this, so it prints normally. -/// Library consumers should call [`suppress_output()`] to silence output. +/// 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(false); +pub static SUPPRESS_STDOUT: AtomicBool = AtomicBool::new(true); /// Suppress all stdout output from the library. /// -/// Call this once before using the scanner when using RustScan as a library -/// dependency to prevent noisy terminal output. Has no effect on `log` -/// crate logging (which is controlled by `RUST_LOG`). +/// 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(); -/// // ... construct and run the scanner normally +/// // ... 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; @@ -101,4 +117,18 @@ mod tests { suppress_output(); assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); } + + #[test] + fn enable_output_clears_flag() { + SUPPRESS_STDOUT.store(true, Ordering::Relaxed); + enable_output(); + assert!(!SUPPRESS_STDOUT.load(Ordering::Relaxed)); + } + + #[test] + fn default_is_suppressed() { + // Reset to default and verify silent-by-default behavior. + SUPPRESS_STDOUT.store(true, Ordering::Relaxed); + assert!(SUPPRESS_STDOUT.load(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(); From 3e06c471f3363ef9f92c94c0129bb45cc90bf6dd Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 12:23:40 +0800 Subject: [PATCH 08/11] fix: serialize toggle unit tests to fix race condition, add fresh-process default-silent test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4 review identified two defects: 1. The four toggle tests in src/lib.rs (suppress_output_sets_flag, suppress_output_is_idempotent, enable_output_clears_flag, default_is_suppressed) shared the SUPPRESS_STDOUT AtomicBool static and raced under parallel test execution. Combined them into a single sequential test (toggle_flag_behavior) to eliminate races. 2. default_is_suppressed manually stored true before asserting, never tested the actual AtomicBool::new(true) initialiser. Added a fresh-process integration test via new example default_silent.rs that emits macros without calling enable_output() and verifies stdout is empty β€” proving the library is silent by default. Also parameterised example_binary() and ensure_example_built() in tests/stdout_suppression.rs to avoid duplication. Fixes: #706 Signed-off-by: Yuchen Fan --- examples/default_silent.rs | 10 +++++ src/lib.rs | 29 ++++++--------- tests/stdout_suppression.rs | 73 +++++++++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 examples/default_silent.rs 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/src/lib.rs b/src/lib.rs index f5f6e9690..c507e2057 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -100,35 +100,30 @@ mod tests { use super::*; use std::sync::atomic::Ordering; - /// Unit test: verify the flag mechanism toggles correctly. - /// The actual stdout suppression effect is verified by the - /// integration test in tests/stdout_suppression.rs. + /// 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 suppress_output_sets_flag() { + fn toggle_flag_behavior() { + // --- suppress_output sets the flag --- SUPPRESS_STDOUT.store(false, Ordering::Relaxed); suppress_output(); assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); - } - #[test] - fn suppress_output_is_idempotent() { + // --- suppress_output is idempotent --- SUPPRESS_STDOUT.store(false, Ordering::Relaxed); suppress_output(); suppress_output(); assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); - } - #[test] - fn enable_output_clears_flag() { + // --- enable_output clears the flag --- SUPPRESS_STDOUT.store(true, Ordering::Relaxed); enable_output(); assert!(!SUPPRESS_STDOUT.load(Ordering::Relaxed)); } - - #[test] - fn default_is_suppressed() { - // Reset to default and verify silent-by-default behavior. - SUPPRESS_STDOUT.store(true, Ordering::Relaxed); - assert!(SUPPRESS_STDOUT.load(Ordering::Relaxed)); - } } diff --git a/tests/stdout_suppression.rs b/tests/stdout_suppression.rs index f64b54e65..7793602db 100644 --- a/tests/stdout_suppression.rs +++ b/tests/stdout_suppression.rs @@ -7,11 +7,10 @@ use std::path::PathBuf; use std::process::Command; -/// Locate the compiled `stdout_check` example binary. -fn example_binary() -> PathBuf { - // Cargo sets CARGO_BIN_EXE_ for bin targets; examples may - // also be available under this key depending on the Cargo version. - if let Ok(p) = std::env::var("CARGO_BIN_EXE_stdout_check") { +/// Locate a compiled example binary by name. +fn example_binary(name: &str) -> PathBuf { + let env_key = format!("CARGO_BIN_EXE_{name}"); + if let Ok(p) = std::env::var(&env_key) { let path = PathBuf::from(p); if path.exists() { return path; @@ -29,26 +28,23 @@ fn example_binary() -> PathBuf { .join("target") .join(profile) .join("examples") - .join("stdout_check") + .join(name) } -/// Build the example if it hasn't been built yet. -fn ensure_example_built() { +/// Build an example by name if it hasn't been built yet. +fn ensure_example_built(name: &str) { let status = Command::new("cargo") - .args(["build", "--example", "stdout_check"]) + .args(["build", "--example", name]) .status() - .expect("failed to run cargo build --example stdout_check"); - assert!( - status.success(), - "cargo build --example stdout_check failed" - ); + .expect("failed to run cargo build"); + assert!(status.success(), "cargo build --example {} failed", name); } #[test] fn suppress_output_actually_silences_macro_output() { - ensure_example_built(); + ensure_example_built("stdout_check"); - let bin = example_binary(); + let bin = example_binary("stdout_check"); assert!( bin.exists(), "example binary not found at {}; run `cargo build --example stdout_check`", @@ -102,3 +98,48 @@ fn suppress_output_actually_silences_macro_output() { 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() { + ensure_example_built("default_silent"); + + let bin = example_binary("default_silent"); + assert!( + bin.exists(), + "example binary not found at {}; run `cargo build --example default_silent`", + bin.display() + ); + + 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 + ); +} From 186c3bdd73dce212653b15f5ed807c396b1d7630 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 12:42:56 +0800 Subject: [PATCH 09/11] fix: append EXE_SUFFIX to example binary path in stdout_suppression test The fallback binary path constructed in example_binary() omitted std::env::consts::EXE_SUFFIX, causing bin.exists() to fail on Windows where example binaries have a .exe extension. Signed-off-by: Yuchen Fan --- tests/stdout_suppression.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/stdout_suppression.rs b/tests/stdout_suppression.rs index 7793602db..b334e8191 100644 --- a/tests/stdout_suppression.rs +++ b/tests/stdout_suppression.rs @@ -28,7 +28,7 @@ fn example_binary(name: &str) -> PathBuf { .join("target") .join(profile) .join("examples") - .join(name) + .join(format!("{}{}", name, std::env::consts::EXE_SUFFIX)) } /// Build an example by name if it hasn't been built yet. From 54b2aef9e38d286e7947fb3ffbf9e11e58308bd6 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 13:33:47 +0800 Subject: [PATCH 10/11] fix: resolve isolation findings in RustScan#706 tests - Use cargo build --message-format=json to resolve release/debug profile mismatch: profile flag now matches cfg!(debug_assertions), and the returned path respects CARGO_TARGET_DIR / workspace layouts. - Save and restore SUPPRESS_STDOUT in toggle_flag_behavior to avoid contaminating parallel unit tests sharing the process-global AtomicBool. Signed-off-by: Yuchen Fan --- src/lib.rs | 7 +++ tests/stdout_suppression.rs | 90 +++++++++++++++++++------------------ 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index c507e2057..1e3f77bee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,6 +110,10 @@ mod tests { /// 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(); @@ -125,5 +129,8 @@ mod tests { 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/tests/stdout_suppression.rs b/tests/stdout_suppression.rs index b334e8191..b01ba75c5 100644 --- a/tests/stdout_suppression.rs +++ b/tests/stdout_suppression.rs @@ -7,49 +7,58 @@ use std::path::PathBuf; use std::process::Command; -/// Locate a compiled example binary by name. -fn example_binary(name: &str) -> PathBuf { - let env_key = format!("CARGO_BIN_EXE_{name}"); - if let Ok(p) = std::env::var(&env_key) { - let path = PathBuf::from(p); - if path.exists() { - return path; - } +/// 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 manifest = - std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env!("CARGO_MANIFEST_DIR").into()); - let profile = if cfg!(debug_assertions) { - "debug" - } else { - "release" - }; - PathBuf::from(manifest) - .join("target") - .join(profile) - .join("examples") - .join(format!("{}{}", name, std::env::consts::EXE_SUFFIX)) -} - -/// Build an example by name if it hasn't been built yet. -fn ensure_example_built(name: &str) { - let status = Command::new("cargo") - .args(["build", "--example", name]) - .status() + let output = Command::new("cargo") + .args(&args) + .output() .expect("failed to run cargo build"); - assert!(status.success(), "cargo build --example {} failed", name); + + 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() { + if line.contains("\"reason\":\"compiler-artifact\"") + && line.contains(&format!("\"name\":\"{}\"", name)) + { + if let Some(start) = line.find("\"executable\":\"") { + let start = start + "\"executable\":\"".len(); + if let Some(end) = line[start..].find('"') { + let path = PathBuf::from(&line[start..start + end]); + 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() { - ensure_example_built("stdout_check"); - - let bin = example_binary("stdout_check"); - assert!( - bin.exists(), - "example binary not found at {}; run `cargo build --example stdout_check`", - bin.display() - ); + let bin = build_example("stdout_check"); let output = Command::new(&bin) .output() @@ -104,14 +113,7 @@ fn suppress_output_actually_silences_macro_output() { /// so all macro output should be suppressed by the initialiser. #[test] fn default_silent_output_is_suppressed() { - ensure_example_built("default_silent"); - - let bin = example_binary("default_silent"); - assert!( - bin.exists(), - "example binary not found at {}; run `cargo build --example default_silent`", - bin.display() - ); + let bin = build_example("default_silent"); let output = Command::new(&bin) .output() From 1532d909b40dd8b9f52f6ba952c75236a99dc0c0 Mon Sep 17 00:00:00 2001 From: Functionhx <2994114386@qq.com> Date: Mon, 13 Jul 2026 13:52:39 +0800 Subject: [PATCH 11/11] fix: use serde_json to parse cargo artifact lines instead of string slicing Naive string slicing on JSON lines fails when CARGO_TARGET_DIR contains escape-worthy characters (backslashes, quotes, control chars). Replace with serde_json::Value deserialization which correctly decodes escape sequences. Signed-off-by: Functionhx <2994114386@qq.com> Signed-off-by: Yuchen Fan --- Cargo.lock | 1 + Cargo.toml | 1 + tests/stdout_suppression.rs | 39 ++++++++++++++++++++++++------------- 3 files changed, 27 insertions(+), 14 deletions(-) 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/tests/stdout_suppression.rs b/tests/stdout_suppression.rs index b01ba75c5..741dc9ea8 100644 --- a/tests/stdout_suppression.rs +++ b/tests/stdout_suppression.rs @@ -32,21 +32,32 @@ fn build_example(name: &str) -> PathBuf { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { - if line.contains("\"reason\":\"compiler-artifact\"") - && line.contains(&format!("\"name\":\"{}\"", name)) + 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) { - if let Some(start) = line.find("\"executable\":\"") { - let start = start + "\"executable\":\"".len(); - if let Some(end) = line[start..].find('"') { - let path = PathBuf::from(&line[start..start + end]); - assert!( - path.exists(), - "cargo-reported binary does not exist: {}", - path.display() - ); - return path; - } - } + 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; } }