Support bracketed IPv6 forwarding specs
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s

This commit is contained in:
DuProcess
2026-07-16 21:38:19 -04:00
parent dd6de42724
commit 1a216205f4
+109 -28
View File
@@ -3951,17 +3951,14 @@ fn parse_dynamic_forwards(raw: &[String]) -> Result<Vec<DynamicForward>> {
} }
fn parse_local_forward(raw: &str) -> Result<LocalForward> { fn parse_local_forward(raw: &str) -> Result<LocalForward> {
let parts = raw.split(':').collect::<Vec<_>>(); let parts = split_forward_spec(raw)?;
let (bind_host, listen, target_host, target_port) = match parts.as_slice() { let (bind_host, listen, target_host, target_port) = match parts.as_slice() {
[listen, target_host, target_port] => { [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!( return Err(anyhow!(
"invalid -L {raw:?}; expected listen_port:target_host:target_port or bind_host:listen_port:target_host:target_port" "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<LocalForward> {
"invalid -L {raw:?}; listen port cannot be 0" "invalid -L {raw:?}; listen port cannot be 0"
); );
anyhow::ensure!( 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" "invalid -L {raw:?}; target port cannot be 0"
); );
Ok(LocalForward { Ok(LocalForward {
bind_host, bind_host,
listen_port, listen_port,
target_host: target_host.to_string(), target_host: target_host.clone(),
target_port, target_port,
}) })
} }
@@ -4007,10 +4008,10 @@ fn parse_remote_forward(raw: &str) -> Result<RemoteForward> {
} }
fn parse_dynamic_forward(raw: &str) -> Result<DynamicForward> { fn parse_dynamic_forward(raw: &str) -> Result<DynamicForward> {
let parts = raw.split(':').collect::<Vec<_>>(); let parts = split_forward_spec(raw)?;
let (bind_host, listen) = match parts.as_slice() { let (bind_host, listen) = match parts.as_slice() {
[listen] => ("127.0.0.1".to_string(), *listen), [listen] => ("127.0.0.1".to_string(), listen),
[bind_host, listen] => ((*bind_host).to_string(), *listen), [bind_host, listen] => (bind_host.to_string(), listen),
_ => { _ => {
return Err(anyhow!( return Err(anyhow!(
"invalid -D {raw:?}; expected listen_port or bind_host:listen_port" "invalid -D {raw:?}; expected listen_port or bind_host:listen_port"
@@ -4033,6 +4034,45 @@ fn parse_dynamic_forward(raw: &str) -> Result<DynamicForward> {
}) })
} }
fn split_forward_spec(raw: &str) -> Result<Vec<String>> {
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 { fn valid_forward_host(host: &str) -> bool {
!host.is_empty() !host.is_empty()
&& !host && !host
@@ -7904,7 +7944,7 @@ async fn start_local_forwards(
stream_ids: Arc<AtomicU64>, stream_ids: Arc<AtomicU64>,
) -> Result<()> { ) -> Result<()> {
for forward in local_forwards { 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) let listener = TcpListener::bind(&bind)
.await .await
.with_context(|| format!("bind local forward {bind}"))?; .with_context(|| format!("bind local forward {bind}"))?;
@@ -7972,7 +8012,7 @@ async fn start_dynamic_forwards(
stream_ids: Arc<AtomicU64>, stream_ids: Arc<AtomicU64>,
) -> Result<()> { ) -> Result<()> {
for forward in dynamic_forwards { 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) let listener = TcpListener::bind(&bind)
.await .await
.with_context(|| format!("bind dynamic forward {bind}"))?; .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_flush_terminal_input_after_contact, should_health_log_client_start,
should_hold_during_startup_gate, should_hold_post_submit_input, should_hold_during_startup_gate, should_hold_post_submit_input,
should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal, should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal,
should_strip_unowned_terminal_reports, split_after_command_submit, split_trace_tokens, should_strip_unowned_terminal_reports, socket_bind_display, split_after_command_submit,
ssh_command_target, ssh_config_uses_proxy, ssh_config_word_for_os, ssh_destination_host, split_forward_spec, split_trace_tokens, ssh_command_target, ssh_config_uses_proxy,
ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports, ssh_config_word_for_os, ssh_destination_host, ssh_username, ssh_with_user, startup_command,
strip_terminal_focus_reports, strip_unowned_terminal_reports, summarize_trace_file, status_ssh_target, strip_stale_mouse_reports, strip_terminal_focus_reports,
summarize_trace_file_with_mode, terminal_private_mode_transition, toml_bare_key_or_quoted, strip_unowned_terminal_reports, summarize_trace_file, summarize_trace_file_with_mode,
top_trace_events, trace_report_warnings, unix_update_script, terminal_private_mode_transition, toml_bare_key_or_quoted, top_trace_events,
update_binary_version_for_installer, update_installer_url, update_version_status, trace_report_warnings, unix_update_script, update_binary_version_for_installer,
upsert_managed_block, valid_forward_host, vscode_command_candidates, update_installer_url, update_version_status, upsert_managed_block, valid_forward_host,
vscode_fallback_command, vscode_safe_alias, wake_repaint_retry_deadline, vscode_command_candidates, vscode_fallback_command, vscode_safe_alias,
windows_command_word, windows_deferred_update_script, windows_effective_url_script, wake_repaint_retry_deadline, windows_command_word, windows_deferred_update_script,
windows_mode_from_readonly, windows_powershell_command_candidates, windows_effective_url_script, windows_mode_from_readonly,
windows_readonly_from_mode, windows_update_script, windows_url_reachable_script, windows_powershell_command_candidates, windows_readonly_from_mode, windows_update_script,
windows_vt_output_mode, windows_url_reachable_script, windows_vt_output_mode,
}; };
use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::config::{ClientConfig, CommandExtension, HostConfig};
use dosh::native::EnvVar; 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] #[test]
fn forward_command_rewrites_to_forward_only_connection() { fn forward_command_rewrites_to_forward_only_connection() {
let args = test_args( let args = test_args(