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"}]"#;