From beab25ef224731487348520df54ca6a1407a0cdb Mon Sep 17 00:00:00 2001 From: Mapika Date: Fri, 31 Jul 2026 23:58:44 +0200 Subject: [PATCH] fix(ssh): make --json actually emit JSON for remote scans With portview installed on the far end the flag was fatal: --json is also the transport this side parses back, so the user's flag was appended to the injected one and the remote clap rejected it twice. Without portview there, agentless collection ignored the flag and printed a table. Both paths now emit JSON, and an empty result is [] rather than prose. --- CHANGELOG.md | 8 +++++++ src/ssh.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17dc45f..6c967e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ ### Fixed +**`portview ssh --json` did not work at all.** It failed one of two ways +depending on the far end. With portview installed there, `--json` is also the +transport this side parses back, so the user's flag was appended to the injected +one and the remote clap rejected `--json --json` — the command died with an SSH +error. Without it, agentless collection succeeded and then rendered a table, +silently ignoring the flag. Both paths now emit JSON, and an empty result is +`[]` rather than prose, since this output gets piped. + **A far-future process creation time panicked the scan on Windows.** Converting a `FILETIME` used `UNIX_EPOCH + Duration`, which panics when the result is unrepresentable. A Windows `SystemTime` is itself `FILETIME`-backed and ends diff --git a/src/ssh.rs b/src/ssh.rs index f07ded5..4699197 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -174,15 +174,29 @@ pub(crate) fn run_ssh( run_ssh_passthrough(&ssh, &args); } _ => { - let mut args = vec!["--json"]; - for arg in &remote_args { - args.push(arg.as_str()); - } - run_ssh_scan(&ssh, &args, use_color, agentless); + let (args, want_json) = build_scan_args(&remote_args); + let args: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + run_ssh_scan(&ssh, &args, use_color, agentless, want_json); } } } +/// Build the remote argument list for a scan, and report whether the user asked +/// for JSON on this end. +/// +/// `--json` is always sent to the remote portview, because it is the transport +/// format this side parses back — which has two consequences worth stating. +/// A user-typed `--json` has to be *filtered out* rather than appended, since +/// clap on the far end rejects a repeated flag and the whole command fails. And +/// the user's intent has to be captured here, because once the flag is in the +/// list there is no way to tell a requested one from the injected one. +fn build_scan_args(remote_args: &[String]) -> (Vec, bool) { + let want_json = remote_args.iter().any(|a| a == "--json"); + let mut args = vec!["--json".to_string()]; + args.extend(remote_args.iter().filter(|a| *a != "--json").cloned()); + (args, want_json) +} + /// Diagnose a remote host without portview installed on it. /// /// The probe brings back the same shapes the local collectors produce, so the @@ -242,7 +256,13 @@ fn run_ssh_tui(ssh: &SshCommand, remote_args: &[&str], use_color: bool) { } } -fn run_ssh_scan(ssh: &SshCommand, remote_args: &[&str], use_color: bool, agentless: bool) { +fn run_ssh_scan( + ssh: &SshCommand, + remote_args: &[&str], + use_color: bool, + agentless: bool, + json: bool, +) { let show_all = remote_args.iter().any(|a| *a == "--all" || *a == "-a"); // Anything that is neither a flag nor the injected --json is a filter: @@ -307,7 +327,10 @@ fn run_ssh_scan(ssh: &SshCommand, remote_args: &[&str], use_color: bool, agentle ports }; - if ports.is_empty() { + if json { + // An empty result is `[]`, not prose: this output gets piped. + println!("{}", crate::ports_json_string(&ports, None)); + } else if ports.is_empty() { println!("No ports found on remote host."); } else { let colors = crate::ColorConfig::from_env(); @@ -493,6 +516,37 @@ fn parse_object(obj: &str) -> Result { mod tests { use super::*; + fn owned(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_user_supplied_json_flag_is_not_sent_twice() { + // Regression: the remote clap rejects `--json --json`, so + // `portview ssh --json` failed outright whenever portview was + // installed on the far end. + let (args, want_json) = build_scan_args(&owned(&["--json"])); + assert_eq!(args, vec!["--json"]); + assert!(want_json); + } + + #[test] + fn json_is_injected_for_transport_without_being_requested() { + let (args, want_json) = build_scan_args(&owned(&["3000"])); + assert_eq!(args, vec!["--json", "3000"]); + assert!( + !want_json, + "the injected transport flag must not be read as a request for JSON" + ); + } + + #[test] + fn other_arguments_survive_alongside_the_json_flag() { + let (args, want_json) = build_scan_args(&owned(&["--all", "--json", "node"])); + assert_eq!(args, vec!["--json", "--all", "node"]); + assert!(want_json); + } + #[test] fn parse_single_port() { let json = r#"[{"port":3000,"protocol":"TCP","pid":1234,"ppid":1,"process":"node","command":"next dev","user":"mark","state":"LISTEN","memory_bytes":248000000,"cpu_seconds":14.3,"children":3,"local_addr":"0.0.0.0"}]"#;