Skip to content

Commit e9f7b77

Browse files
committed
fix(ssh): preserve unresolved hostname precedence
1 parent b76961f commit e9f7b77

2 files changed

Lines changed: 144 additions & 8 deletions

File tree

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

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,126 @@ describe("ssh config", () => {
287287
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
288288
);
289289

290+
it.effect("preserves first-value precedence around unresolved Match conditions", () =>
291+
Effect.gen(function* () {
292+
const fs = yield* FileSystem.FileSystem;
293+
const path = yield* Path.Path;
294+
const fixtures = [
295+
{
296+
config:
297+
'Match exec "test -f ~/.work"\n HostName conditional.example.test\nHost work\n HostName work.example.test\n',
298+
included: null,
299+
knownHosts: ["conditional.example.test", "work.example.test"],
300+
expected: [
301+
["conditional.example.test", "conditional.example.test"],
302+
["work", "work"],
303+
["work.example.test", "work.example.test"],
304+
],
305+
},
306+
{
307+
config:
308+
'Match exec "test -f ~/.work"\n Include guarded.conf\nHost work\n HostName work.example.test\n',
309+
included: "Host work\n HostName included.example.test\n",
310+
knownHosts: ["included.example.test", "work.example.test"],
311+
expected: [
312+
["included.example.test", "included.example.test"],
313+
["work", "work"],
314+
["work.example.test", "work.example.test"],
315+
],
316+
},
317+
{
318+
config:
319+
'Host work\n HostName work.example.test\nMatch exec "test -f ~/.work"\n HostName conditional.example.test\n',
320+
included: null,
321+
knownHosts: ["conditional.example.test", "work.example.test"],
322+
expected: [
323+
["conditional.example.test", "conditional.example.test"],
324+
["work", "work.example.test"],
325+
],
326+
},
327+
{
328+
config: "Host other\n Include guarded.conf\nHost work\n HostName work.example.test\n",
329+
included: 'Match exec "test -f ~/.other"\n HostName unrelated.example.test\n',
330+
knownHosts: ["unrelated.example.test", "work.example.test"],
331+
expected: [
332+
["other", "other"],
333+
["unrelated.example.test", "unrelated.example.test"],
334+
["work", "work.example.test"],
335+
],
336+
},
337+
];
338+
339+
for (const fixture of fixtures) {
340+
const homeDir = yield* makeTempHomeDir();
341+
const sshDir = path.join(homeDir, ".ssh");
342+
yield* fs.makeDirectory(sshDir);
343+
yield* fs.writeFileString(path.join(sshDir, "config"), fixture.config);
344+
if (fixture.included !== null) {
345+
yield* fs.writeFileString(path.join(sshDir, "guarded.conf"), fixture.included);
346+
}
347+
yield* fs.writeFileString(
348+
path.join(sshDir, "known_hosts"),
349+
fixture.knownHosts.map((hostname) => `${hostname} ssh-ed25519 AAAA`).join("\n"),
350+
);
351+
352+
const hosts = yield* discoverSshHosts({ homeDir });
353+
assert.deepEqual(
354+
hosts.map(({ alias, hostname }) => [alias, hostname]),
355+
fixture.expected,
356+
);
357+
}
358+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
359+
);
360+
361+
it.effect("matches quoted Match originalhost patterns", () =>
362+
Effect.gen(function* () {
363+
const fs = yield* FileSystem.FileSystem;
364+
const path = yield* Path.Path;
365+
for (const fixture of [
366+
{
367+
match: 'Match originalhost "work"',
368+
matchHostname: "work.example.test",
369+
knownHosts: ["work.example.test"],
370+
expected: [["work", "work.example.test"]],
371+
},
372+
{
373+
match: "Match originalhost 'other,work'",
374+
matchHostname: "work.example.test",
375+
knownHosts: ["work.example.test"],
376+
expected: [["work", "work.example.test"]],
377+
},
378+
{
379+
match: 'Match originalhost "other"',
380+
matchHostname: "other.example.test",
381+
knownHosts: ["other.example.test", "work.example.test"],
382+
expected: [
383+
["other.example.test", "other.example.test"],
384+
["work", "work"],
385+
["work.example.test", "work.example.test"],
386+
],
387+
},
388+
]) {
389+
const homeDir = yield* makeTempHomeDir();
390+
const sshDir = path.join(homeDir, ".ssh");
391+
yield* fs.makeDirectory(sshDir);
392+
yield* fs.writeFileString(
393+
path.join(sshDir, "config"),
394+
`${fixture.match}\n HostName ${fixture.matchHostname}\nHost work\n`,
395+
);
396+
yield* fs.writeFileString(
397+
path.join(sshDir, "known_hosts"),
398+
fixture.knownHosts.map((hostname) => `${hostname} ssh-ed25519 AAAA`).join("\n"),
399+
);
400+
401+
const hosts = yield* discoverSshHosts({ homeDir });
402+
assert.deepEqual(
403+
hosts.map(({ alias, hostname }) => [alias, hostname]),
404+
fixture.expected,
405+
);
406+
}
407+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
408+
);
409+
290410
it.effect("bounds nested Includes and keeps reading the outer file", () =>
291411
Effect.gen(function* () {
292412
const fs = yield* FileSystem.FileSystem;

‎packages/ssh/src/config.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,12 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) {
9191

9292
interface SshHostNameRule {
9393
readonly guards: ReadonlyArray<ReadonlyArray<string> | null>;
94-
readonly patterns: ReadonlyArray<string>;
94+
readonly patterns: ReadonlyArray<string> | null;
9595
readonly hostname: string;
9696
}
9797

98+
type SshHostNameRuleMatch = "match" | "no-match" | "unresolved";
99+
98100
function expandConfiguredHostname(hostname: string, alias: string): string | null {
99101
let supported = true;
100102
const expanded = hostname.replace(/%(.?)/gsu, (_, token: string) => {
@@ -118,6 +120,14 @@ function matchesHostPatterns(alias: string, patterns: ReadonlyArray<string>): bo
118120
return matched;
119121
}
120122

123+
function matchSshHostNameRule(alias: string, rule: SshHostNameRule): SshHostNameRuleMatch {
124+
const scopes = [...rule.guards, rule.patterns];
125+
if (scopes.some((patterns) => patterns !== null && !matchesHostPatterns(alias, patterns))) {
126+
return "no-match";
127+
}
128+
return scopes.some((patterns) => patterns === null) ? "unresolved" : "match";
129+
}
130+
121131
const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
122132
filePath: string,
123133
visited = new Set<string>(),
@@ -195,10 +205,10 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
195205
condition === "all" && rawArgs.length === 1
196206
? ["*"]
197207
: condition === "originalhost" && rawArgs.length === 2
198-
? (rawArgs[1]?.split(",") ?? [])
208+
? (rawArgs[1]?.replace(/^(["'])(.*)\1$/u, "$2").split(",") ?? [])
199209
: null;
200210
}
201-
if (normalizedDirective === "hostname" && context.patterns && context.patterns.length > 0) {
211+
if (normalizedDirective === "hostname") {
202212
const hostname = rawArgs[0]?.replace(/^(["'])(.*)\1$/u, "$2");
203213
if (hostname) {
204214
hostnameRules.push({
@@ -308,11 +318,17 @@ export const discoverSshHosts = Effect.fnUntraced(
308318
const configuredTargets = new Set<string>();
309319

310320
for (const alias of configAliases) {
311-
const configuredHostname = hostnameRules.find(
312-
(rule) =>
313-
rule.guards.every((guard) => guard !== null && matchesHostPatterns(alias, guard)) &&
314-
matchesHostPatterns(alias, rule.patterns),
315-
)?.hostname;
321+
let configuredHostname: string | undefined;
322+
for (const rule of hostnameRules) {
323+
const ruleMatch = matchSshHostNameRule(alias, rule);
324+
if (ruleMatch === "no-match") {
325+
continue;
326+
}
327+
if (ruleMatch === "match") {
328+
configuredHostname = rule.hostname;
329+
}
330+
break;
331+
}
316332
const hostname = configuredHostname
317333
? (expandConfiguredHostname(configuredHostname, alias) ?? alias)
318334
: alias;

0 commit comments

Comments
 (0)