diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index e9a22e4..d50aa20 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -3951,17 +3951,14 @@ fn parse_dynamic_forwards(raw: &[String]) -> Result> { } fn parse_local_forward(raw: &str) -> Result { - let parts = raw.split(':').collect::>(); + let parts = split_forward_spec(raw)?; let (bind_host, listen, target_host, target_port) = match parts.as_slice() { [listen, target_host, target_port] => { - ("127.0.0.1".to_string(), *listen, *target_host, *target_port) + ("127.0.0.1".to_string(), listen, target_host, target_port) + } + [bind_host, listen, target_host, target_port] => { + (bind_host.to_string(), listen, target_host, target_port) } - [bind_host, listen, target_host, target_port] => ( - (*bind_host).to_string(), - *listen, - *target_host, - *target_port, - ), _ => { return Err(anyhow!( "invalid -L {raw:?}; expected listen_port:target_host:target_port or bind_host:listen_port:target_host:target_port" @@ -3985,13 +3982,17 @@ fn parse_local_forward(raw: &str) -> Result { "invalid -L {raw:?}; listen port cannot be 0" ); anyhow::ensure!( - target_port != 0 || matches!(target_host, FILE_STREAM_SENTINEL | EXEC_STREAM_SENTINEL), + target_port != 0 + || matches!( + target_host.as_str(), + FILE_STREAM_SENTINEL | EXEC_STREAM_SENTINEL + ), "invalid -L {raw:?}; target port cannot be 0" ); Ok(LocalForward { bind_host, listen_port, - target_host: target_host.to_string(), + target_host: target_host.clone(), target_port, }) } @@ -4007,10 +4008,10 @@ fn parse_remote_forward(raw: &str) -> Result { } fn parse_dynamic_forward(raw: &str) -> Result { - let parts = raw.split(':').collect::>(); + let parts = split_forward_spec(raw)?; let (bind_host, listen) = match parts.as_slice() { - [listen] => ("127.0.0.1".to_string(), *listen), - [bind_host, listen] => ((*bind_host).to_string(), *listen), + [listen] => ("127.0.0.1".to_string(), listen), + [bind_host, listen] => (bind_host.to_string(), listen), _ => { return Err(anyhow!( "invalid -D {raw:?}; expected listen_port or bind_host:listen_port" @@ -4033,6 +4034,45 @@ fn parse_dynamic_forward(raw: &str) -> Result { }) } +fn split_forward_spec(raw: &str) -> Result> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut bracketed = false; + let mut chars = raw.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '[' if current.is_empty() && !bracketed => { + bracketed = true; + } + ']' if bracketed => { + bracketed = false; + if !matches!(chars.peek(), Some(':') | None) { + bail!( + "invalid forwarding spec {raw:?}; bracketed host must be followed by ':'" + ); + } + } + ':' if !bracketed => { + parts.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if bracketed { + bail!("invalid forwarding spec {raw:?}; missing closing ']'"); + } + parts.push(current); + Ok(parts) +} + +fn socket_bind_display(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + fn valid_forward_host(host: &str) -> bool { !host.is_empty() && !host @@ -7904,7 +7944,7 @@ async fn start_local_forwards( stream_ids: Arc, ) -> Result<()> { for forward in local_forwards { - let bind = format!("{}:{}", forward.bind_host, forward.listen_port); + let bind = socket_bind_display(&forward.bind_host, forward.listen_port); let listener = TcpListener::bind(&bind) .await .with_context(|| format!("bind local forward {bind}"))?; @@ -7972,7 +8012,7 @@ async fn start_dynamic_forwards( stream_ids: Arc, ) -> Result<()> { for forward in dynamic_forwards { - let bind = format!("{}:{}", forward.bind_host, forward.listen_port); + let bind = socket_bind_display(&forward.bind_host, forward.listen_port); let listener = TcpListener::bind(&bind) .await .with_context(|| format!("bind dynamic forward {bind}"))?; @@ -10662,19 +10702,19 @@ mod tests { should_flush_terminal_input_after_contact, should_health_log_client_start, should_hold_during_startup_gate, should_hold_post_submit_input, should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal, - should_strip_unowned_terminal_reports, split_after_command_submit, split_trace_tokens, - ssh_command_target, ssh_config_uses_proxy, ssh_config_word_for_os, ssh_destination_host, - ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, - strip_terminal_focus_reports, strip_unowned_terminal_reports, summarize_trace_file, - summarize_trace_file_with_mode, terminal_private_mode_transition, toml_bare_key_or_quoted, - top_trace_events, trace_report_warnings, unix_update_script, - update_binary_version_for_installer, update_installer_url, update_version_status, - upsert_managed_block, valid_forward_host, vscode_command_candidates, - vscode_fallback_command, vscode_safe_alias, wake_repaint_retry_deadline, - windows_command_word, windows_deferred_update_script, windows_effective_url_script, - windows_mode_from_readonly, windows_powershell_command_candidates, - windows_readonly_from_mode, windows_update_script, windows_url_reachable_script, - windows_vt_output_mode, + should_strip_unowned_terminal_reports, socket_bind_display, split_after_command_submit, + split_forward_spec, split_trace_tokens, ssh_command_target, ssh_config_uses_proxy, + ssh_config_word_for_os, ssh_destination_host, ssh_username, ssh_with_user, startup_command, + status_ssh_target, strip_stale_mouse_reports, strip_terminal_focus_reports, + strip_unowned_terminal_reports, summarize_trace_file, summarize_trace_file_with_mode, + terminal_private_mode_transition, toml_bare_key_or_quoted, top_trace_events, + trace_report_warnings, unix_update_script, update_binary_version_for_installer, + update_installer_url, update_version_status, upsert_managed_block, valid_forward_host, + vscode_command_candidates, vscode_fallback_command, vscode_safe_alias, + wake_repaint_retry_deadline, windows_command_word, windows_deferred_update_script, + windows_effective_url_script, windows_mode_from_readonly, + windows_powershell_command_candidates, windows_readonly_from_mode, windows_update_script, + windows_url_reachable_script, windows_vt_output_mode, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -13473,6 +13513,47 @@ mod tests { ); } + #[test] + fn forward_parser_accepts_bracketed_ipv6_hosts() { + assert_eq!( + split_forward_spec("[::1]:8080:[2001:db8::1]:80").unwrap(), + vec!["::1", "8080", "2001:db8::1", "80"] + ); + assert_eq!( + parse_local_forward("[::1]:8080:[2001:db8::1]:80").unwrap(), + LocalForward { + bind_host: "::1".to_string(), + listen_port: 8080, + target_host: "2001:db8::1".to_string(), + target_port: 80, + } + ); + assert_eq!( + parse_local_forward("8080:[::1]:80").unwrap(), + LocalForward { + bind_host: "127.0.0.1".to_string(), + listen_port: 8080, + target_host: "::1".to_string(), + target_port: 80, + } + ); + assert_eq!( + parse_dynamic_forward("[::1]:1080").unwrap(), + DynamicForward { + bind_host: "::1".to_string(), + listen_port: 1080, + } + ); + assert_eq!(socket_bind_display("::1", 1080), "[::1]:1080"); + assert_eq!(socket_bind_display("127.0.0.1", 1080), "127.0.0.1:1080"); + } + + #[test] + fn forward_parser_rejects_malformed_bracketed_hosts() { + assert!(parse_local_forward("[::1:8080:host:80").is_err()); + assert!(parse_local_forward("[::1]8080:host:80").is_err()); + } + #[test] fn forward_command_rewrites_to_forward_only_connection() { let args = test_args(