Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@

### Fixed

**`portview ssh <host> --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
Expand Down
68 changes: 61 additions & 7 deletions src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -493,6 +516,37 @@ fn parse_object(obj: &str) -> Result<PortInfo, String> {
mod tests {
use super::*;

fn owned(args: &[&str]) -> Vec<String> {
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 <host> --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"}]"#;
Expand Down
Loading