zed: Support IPv6 hosts in SCP-style SSH URLs (#62157)

# Objective

- Support opening remote paths from SCP-style SSH URLs that use
bracketed IPv6 hosts, such as `ssh://[2600::]:~/foo`.

## Solution

- Validate SSH hosts with `url::Host::parse`, which supports bracketed
IPv6 addresses while continuing to reject malformed or ambiguous hosts.
- Add test coverage for IPv6 URLs with usernames, home-relative paths,
absolute paths, and explicit ports.
- Add rejection tests for unbracketed IPv6 addresses and ambiguous
port-plus-SCP-path syntax.

## Testing

- Ran `cargo fmt --all -- --check`.
- Ran `cargo test -p zed test_parse_ssh_urls -- --nocapture`.
- Ran `cargo test -p zed test_reject_ssh_urls -- --nocapture`.
- All targeted tests passed on macOS. No additional platform-specific
testing is expected to be necessary because the change only affects URL
parsing.

Release Notes:

- Fixed opening remote paths from SCP-style SSH URLs with IPv6 hosts.

Signed-off-by: Xiaobo Liu <cppcoffee@gmail.com>
This commit is contained in:
Xiaobo Liu authored and GitHub committed 2026-08-20 05:39:38 +00:00
1 parent 32a0e813a5
commit a58fff1334
1 file changed
+36 -4
+36 -4
View File
@@ -329,8 +329,7 @@ fn parse_ssh_url(url: &str) -> Result<url::Url> {
// They are unsupported by Url::parse, but can be normalized into a Url.
// SCPUrl("ssh://user@host:~/relpath") => Url("ssh://user@host/~/relpath")
// SCPUrl("ssh://user@host:/abs/path") => Url("ssh://user@host/abs/path")
//
// TODO: Add IPv6 support: "ssh://[2600::]:~/foo"
// SCPUrl("ssh://[2600::]:~/foo") => Url("ssh://[2600::]/~/foo")
let ssh_target = url
.strip_prefix("ssh://")
.with_context(|| format!("invalid ssh url: {url}"))?;
@@ -347,7 +346,7 @@ fn parse_ssh_url(url: &str) -> Result<url::Url> {
.rsplit_once('@')
.map_or((None, authority), |(userinfo, host)| (Some(userinfo), host));
anyhow::ensure!(
!host.is_empty() && !host.starts_with('[') && !host.contains(':'),
!host.is_empty() && url::Host::parse(host).is_ok(),
"invalid ssh url: {url}"
);
@@ -1235,6 +1234,38 @@ mod tests {
None,
"/project",
),
(
"ssh://[2600::]:~/foo",
Some("ssh://[2600::]/~/foo"),
"2600::",
None,
None,
"/~/foo",
),
(
"ssh://me@[2001:db8::1]:~/project",
Some("ssh://me@[2001:db8::1]/~/project"),
"2001:db8::1",
Some("me"),
None,
"/~/project",
),
(
"ssh://me@[::1]:/tmp/file",
Some("ssh://me@[::1]/tmp/file"),
"::1",
Some("me"),
None,
"/tmp/file",
),
(
"ssh://[2001:db8::2]:2222/tmp",
Some("ssh://[2001:db8::2]:2222/tmp"),
"2001:db8::2",
None,
Some(2222),
"/tmp",
),
];
for (input, expected_url, host, username, port, path) in cases {
@@ -1362,7 +1393,8 @@ mod tests {
for input in [
"ssh://me@localhost:code/vibes/mine-bot",
"ssh://me@localhost:2222:~/project",
"ssh://me@[2001:db8::1]:~/project",
"ssh://2600:::~/foo",
"ssh://[2600::]:2222:~/foo",
] {
let result = cx.update(|cx| {
OpenRequest::parse(