Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null {
return null;
}

// Reassemble Punycode labels (xn--...) the split broke apart: when the
// prefix ends in ".xn", the cut landed inside an "xn--..." label.
while (parts.length >= 2 && parts[0].endsWith(".xn")) {
parts.splice(0, 2, `${parts[0]}--${parts[1]}`);
}

Comment on lines +73 to +78
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently double dashes are only disallowed as the third and fourth characters, and even then it can depend on the tld?

I imagine this is exceedingly rare, but it means a domain like https://test--ドメイン名例.jp (xn--test---8o4epknhtgo724awkl.jp, becomes a triple dash apparently) is actually valid. And also things like test--domain.com are valid.

So I think to fully fix any domain issues we might have to take more of a parser approach. Something like scan through until the last dot, then scan to the first -- that is not part of a ---, then split on everything after that index. Something like that lol

The current change still seems like an improvement though.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this was considered but it would tie us to Coderd semantics. Like this only works because usernames cannot have dots and thus we can keep appending the segments until there is no dot (this would work on all domains even if rare). Do you prefer this approach?

// It has the proper prefix, so this is probably a Coder host name.
// Validate the SSH host name. Including the prefix, we expect at least
// three parts, or four if including the agent.
Expand Down
224 changes: 150 additions & 74 deletions test/unit/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import os from "node:os";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";

import {
type AuthorityParts,
countSubstring,
escapeCommandArg,
expandPath,
Expand All @@ -13,84 +14,159 @@ import {
} from "@/util";

describe("parseRemoteAuthority", () => {
it("ignore unrelated authorities", () => {
const tests = [
"vscode://ssh-remote+some-unrelated-host.com",
"vscode://ssh-remote+coder-vscode",
"vscode://ssh-remote+coder-vscode-test",
"vscode://ssh-remote+coder-vscode-test--foo--bar",
"vscode://ssh-remote+coder-vscode-foo--bar",
"vscode://ssh-remote+coder--foo--bar",
];
for (const test of tests) {
expect(parseRemoteAuthority(test)).toBe(null);
}
it.each([
"vscode://ssh-remote+some-unrelated-host.com",
"vscode://ssh-remote+coder-vscode",
"vscode://ssh-remote+coder-vscode-test",
"vscode://ssh-remote+coder-vscode-test--foo--bar",
"vscode://ssh-remote+coder-vscode-foo--bar",
"vscode://ssh-remote+coder--foo--bar",
])("ignores unrelated authority: %s", (input) => {
expect(parseRemoteAuthority(input)).toBe(null);
});

it("should error on invalid authorities", () => {
const tests = [
"vscode://ssh-remote+coder-vscode--foo",
"vscode://ssh-remote+coder-vscode--",
"vscode://ssh-remote+coder-vscode--foo--",
"vscode://ssh-remote+coder-vscode--foo--bar--",
];
for (const test of tests) {
expect(() => parseRemoteAuthority(test)).toThrow("Invalid");
}
it.each([
"vscode://ssh-remote+coder-vscode--foo",
"vscode://ssh-remote+coder-vscode--",
"vscode://ssh-remote+coder-vscode--foo--",
"vscode://ssh-remote+coder-vscode--foo--bar--",
])("rejects invalid authority: %s", (input) => {
expect(() => parseRemoteAuthority(input)).toThrow("Invalid");
});

it("should parse authority", () => {
expect(
parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar"),
).toStrictEqual({
agent: "",
sshHost: "coder-vscode--foo--bar",
safeHostname: "",
username: "foo",
workspace: "bar",
});
expect(
parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz"),
).toStrictEqual({
agent: "baz",
sshHost: "coder-vscode--foo--bar--baz",
safeHostname: "",
username: "foo",
workspace: "bar",
});
expect(
parseRemoteAuthority(
"vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar",
),
).toStrictEqual({
agent: "",
sshHost: "coder-vscode.dev.coder.com--foo--bar",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
});
expect(
parseRemoteAuthority(
"vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz",
),
).toStrictEqual({
agent: "baz",
sshHost: "coder-vscode.dev.coder.com--foo--bar--baz",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
});
expect(
parseRemoteAuthority(
"vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz",
),
).toStrictEqual({
agent: "baz",
sshHost: "coder-vscode.dev.coder.com--foo--bar.baz",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
});
interface ParseCase {
label: string;
input: string;
expected: AuthorityParts;
}

it.each<ParseCase>([
{
label: "legacy form, no agent",
input: "vscode://ssh-remote+coder-vscode--foo--bar",
expected: {
agent: "",
sshHost: "coder-vscode--foo--bar",
safeHostname: "",
username: "foo",
workspace: "bar",
},
},
{
label: "legacy form with agent",
input: "vscode://ssh-remote+coder-vscode--foo--bar--baz",
expected: {
agent: "baz",
sshHost: "coder-vscode--foo--bar--baz",
safeHostname: "",
username: "foo",
workspace: "bar",
},
},
{
label: "with hostname, no agent",
input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar",
expected: {
agent: "",
sshHost: "coder-vscode.dev.coder.com--foo--bar",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
},
},
{
label: "with hostname and -- agent",
input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz",
expected: {
agent: "baz",
sshHost: "coder-vscode.dev.coder.com--foo--bar--baz",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
},
},
{
label: "with hostname and . agent",
input: "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz",
expected: {
agent: "baz",
sshHost: "coder-vscode.dev.coder.com--foo--bar.baz",
safeHostname: "dev.coder.com",
username: "foo",
workspace: "bar",
},
},
{
label: "Punycode label in hostname",
input:
"vscode://ssh-remote+coder-vscode.dev.coder.xn--eckwd4c7cu47r2wf.jp--foo--bar",
expected: {
agent: "",
sshHost: "coder-vscode.dev.coder.xn--eckwd4c7cu47r2wf.jp--foo--bar",
safeHostname: "dev.coder.xn--eckwd4c7cu47r2wf.jp",
username: "foo",
workspace: "bar",
},
},
{
label: "Punycode hostname with -- agent",
input:
"vscode://ssh-remote+coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar--baz",
expected: {
agent: "baz",
sshHost: "coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar--baz",
safeHostname: "xn--eckwd4c7cu47r2wf.jp",
username: "foo",
workspace: "bar",
},
},
{
label: "Punycode hostname with . agent",
input:
"vscode://ssh-remote+coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar.baz",
expected: {
agent: "baz",
sshHost: "coder-vscode.xn--eckwd4c7cu47r2wf.jp--foo--bar.baz",
safeHostname: "xn--eckwd4c7cu47r2wf.jp",
username: "foo",
workspace: "bar",
},
},
{
label: "multiple Punycode labels",
input: "vscode://ssh-remote+coder-vscode.xn--abc.xn--def.com--foo--bar",
expected: {
agent: "",
sshHost: "coder-vscode.xn--abc.xn--def.com--foo--bar",
safeHostname: "xn--abc.xn--def.com",
username: "foo",
workspace: "bar",
},
},
{
label: "apex Punycode",
input: "vscode://ssh-remote+coder-vscode.xn--p1ai--owner--ws",
expected: {
agent: "",
sshHost: "coder-vscode.xn--p1ai--owner--ws",
safeHostname: "xn--p1ai",
username: "owner",
workspace: "ws",
},
},
{
label: "consecutive apex Punycode labels",
input: "vscode://ssh-remote+coder-vscode.xn--p1ai.xn--abc--owner--ws",
expected: {
agent: "",
sshHost: "coder-vscode.xn--p1ai.xn--abc--owner--ws",
safeHostname: "xn--p1ai.xn--abc",
username: "owner",
workspace: "ws",
},
},
])("parses $label", ({ input, expected }) => {
expect(parseRemoteAuthority(input)).toStrictEqual(expected);
});
});

Expand Down
Loading