Skip to content

Commit 07e83b6

Browse files
committed
fix(ssh): preserve scoped addresses and literal usernames
1 parent d733412 commit 07e83b6

3 files changed

Lines changed: 49 additions & 33 deletions

File tree

‎packages/ssh/src/command.test.ts‎

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe("ssh command", () => {
8787
it.effect("keeps a discovered SSH URI valid after resolving its user", () =>
8888
Effect.gen(function* () {
8989
for (const [alias, hostname, expected] of [
90-
["ssh://host.example:2222", "host.example", "ssh://[email protected]:2222"],
90+
["ssh://host.example:2222", "host.example", "[email protected]"],
9191
["ssh://[::1]:2222", "::1", "alice@::1"],
9292
]) {
9393
const resolved = parseSshResolveOutput(
@@ -101,27 +101,32 @@ describe("ssh command", () => {
101101
);
102102

103103
it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
104-
"passes discovered IPv6 destinations to OpenSSH without losing the user or port",
104+
"passes SSH URI destinations to OpenSSH with their exact user and port",
105105
() =>
106106
Effect.gen(function* () {
107107
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
108-
for (const username of [null, "developer"]) {
109-
const target = {
110-
alias: "ssh://[2001:db8::1]:2222",
111-
hostname: "2001:db8::1",
112-
username,
113-
port: null,
114-
};
115-
const hostSpec = yield* buildSshHostSpecEffect(target);
116-
const child = yield* spawner.spawn(
117-
ChildProcess.make("ssh", ["-F", "/dev/null", "-G", ...baseSshArgs(target), hostSpec]),
118-
);
119-
const stdout = yield* collectProcessOutput(child.stdout);
120-
assert.equal(Number(yield* child.exitCode), 0);
121-
assert.include(stdout, "port 2222\n");
122-
assert.include(stdout, "hostname 2001:db8::1\n");
123-
if (username) assert.include(stdout, `user ${username}\n`);
108+
for (const hostname of ["host.example", "2001:db8::1", "fe80::1%fixture0"]) {
109+
for (const username of [null, "developer+ops", "developer%ops"]) {
110+
const alias = `ssh://${hostname.includes(":") ? `[${hostname}]` : hostname}:2222`;
111+
const target = { alias, hostname, username, port: null };
112+
const hostSpec = yield* buildSshHostSpecEffect(target);
113+
const child = yield* spawner.spawn(
114+
ChildProcess.make("ssh", ["-F", "/dev/null", "-G", ...baseSshArgs(target), hostSpec]),
115+
);
116+
const stdout = yield* collectProcessOutput(child.stdout);
117+
assert.equal(Number(yield* child.exitCode), 0);
118+
assert.include(stdout, "port 2222\n");
119+
assert.include(stdout, `hostname ${hostname}\n`);
120+
if (username) assert.include(stdout, `user ${username}\n`);
121+
}
124122
}
123+
const encoded = {
124+
alias: "ssh://developer%2Bops%[email protected]:2222",
125+
hostname: "host.example",
126+
username: null,
127+
port: null,
128+
};
129+
assert.equal(yield* buildSshHostSpecEffect(encoded), "developer+ops%@host.example");
125130
}).pipe(Effect.provide(NodeServices.layer)),
126131
);
127132

‎packages/ssh/src/command.ts‎

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,24 +79,34 @@ export function remoteStateKey(target: DesktopSshEnvironmentTarget): string {
7979
.slice(0, 16);
8080
}
8181

82-
function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
82+
// SSH authorities permit scoped IPv6 addresses that WHATWG URL rejects.
83+
function parseSshDestination(target: DesktopSshEnvironmentTarget) {
8384
const destination = target.alias.trim() || target.hostname.trim();
8485
if (destination.length === 0) {
8586
throw new Error("SSH target is missing its alias/hostname.");
8687
}
87-
if (destination.startsWith("ssh://")) {
88-
const uri = new URL(destination);
89-
if (uri.hostname.startsWith("[")) {
90-
const username = target.username ?? (uri.username ? decodeURIComponent(uri.username) : null);
91-
const hostname = uri.hostname.slice(1, -1);
92-
return username ? `${username}@${hostname}` : hostname;
93-
}
94-
if (target.username) {
95-
uri.username = target.username;
96-
return uri.toString();
97-
}
88+
if (!destination.startsWith("ssh://")) {
89+
return { hostname: destination, username: target.username, port: target.port };
9890
}
99-
return target.username ? `${target.username}@${destination}` : destination;
91+
const authority = /^ssh:\/\/(?:([^@/?#:]+)@)?(\[[^\]]+\]|[^:/?#@]+)(?::(\d+))?\/?$/u.exec(
92+
destination,
93+
);
94+
if (!authority?.[2]) throw new Error("SSH URI is invalid.");
95+
const hostname = authority[2].startsWith("[") ? authority[2].slice(1, -1) : authority[2];
96+
const port = authority[3] === undefined ? null : Number(authority[3]);
97+
if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) {
98+
throw new Error("SSH URI port is invalid.");
99+
}
100+
return {
101+
hostname,
102+
username: target.username ?? (authority[1] ? decodeURIComponent(authority[1]) : null),
103+
port: target.port ?? port,
104+
};
105+
}
106+
107+
function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string {
108+
const { hostname, username } = parseSshDestination(target);
109+
return username ? `${username}@${hostname}` : hostname;
100110
}
101111

102112
export const buildSshHostSpecEffect = (
@@ -114,8 +124,7 @@ export function baseSshArgs(
114124
target: DesktopSshEnvironmentTarget,
115125
input?: { readonly batchMode?: "yes" | "no" },
116126
): string[] {
117-
const destination = target.alias.trim() || target.hostname.trim();
118-
const port = target.port ?? (destination.startsWith("ssh://") ? new URL(destination).port : null);
127+
const { port } = parseSshDestination(target);
119128
return [
120129
"-o",
121130
`BatchMode=${input?.batchMode ?? "no"}`,

‎packages/ssh/src/config.knownHosts.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ it.effect("preserves distinct known-host ports and IPv6 destinations", () =>
2121
"[host.example.com]:2223 ssh-ed25519 AAAA",
2222
"[host.example.com]:2222 ssh-rsa BBBB",
2323
"[2001:db8::1]:2200 ssh-ed25519 AAAA",
24+
"[fe80::1%fixture0]:2222 ssh-ed25519 AAAA",
2425
"[invalid.example.com]:65536 ssh-ed25519 AAAA",
2526
"[invalid.example.com]:abc ssh-ed25519 AAAA",
2627
"[invalid.example.com]: ssh-ed25519 AAAA",
@@ -38,6 +39,7 @@ it.effect("preserves distinct known-host ports and IPv6 destinations", () =>
3839
{ alias: "2001:db8::2", hostname: "2001:db8::2", port: null },
3940
{ alias: "host.example.com", hostname: "host.example.com", port: null },
4041
{ alias: "ssh://[2001:db8::1]:2200", hostname: "2001:db8::1", port: 2200 },
42+
{ alias: "ssh://[fe80::1%fixture0]:2222", hostname: "fe80::1%fixture0", port: 2222 },
4143
{ alias: "ssh://host.example.com:2222", hostname: "host.example.com", port: 2222 },
4244
{ alias: "ssh://host.example.com:2223", hostname: "host.example.com", port: 2223 },
4345
{ alias: "ssh://plain.example.com:2201", hostname: "plain.example.com", port: 2201 },

0 commit comments

Comments
 (0)