1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import { discoverSshHosts, parseKnownHosts } from "./config.ts";
8
9function makeTempHomeDir() {
10 return Effect.gen(function* () {
11 const fs = yield* FileSystem.FileSystem;
12 return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-test-" });
13 });
14}
15
16describe("ssh config", () => {
17 it.effect("deduplicates hosts through quoted, escaped, and Unicode Includes", () =>
18 Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const homeDir = yield* makeTempHomeDir();
22 const sshDir = path.join(homeDir, ".ssh");
23 const quotedDir = path.join(homeDir, "quoted dir");
24 yield* fs.makeDirectory(sshDir);
25 yield* fs.makeDirectory(quotedDir);
26 yield* fs.writeFileString(
27 path.join(quotedDir, "team#1=a.conf"),
28 'Host quoted\n HostName quoted".example.com"\nHost *\n',
29 );
30 yield* fs.writeFileString(
31 path.join(homeDir, "escaped space.conf"),
32 "Host escaped\n HostName escaped.example.com\nHost *\n",
33 );
34 const unicodeFiles = [
35 "\u00a0team\u00a0one.conf",
36 "team\u2003one.conf",
37 "team\u2028one.conf",
38 "team\u2029one.conf",
39 ].flatMap((file) => [file, `quoted-${file}`]);
40 for (const [index, file] of unicodeFiles.entries()) {
41 yield* fs.writeFileString(
42 path.join(sshDir, file),
43 `Host unicode-${index}\n HostName unicode-${index}.example.com\nHost *\n`,
44 );
45 }
46 yield* fs.writeFileString(
47 path.join(sshDir, "config"),
48 [
49 `Include "${path.join(quotedDir, "team#1=a.conf")}" # comment`,
50 `Include ${path.join(homeDir, "escaped space.conf").replaceAll(" ", "\\ ")}`,
51 `Include ${unicodeFiles.map((file, index) => (index % 2 === 0 ? file : `"${file}"`)).join("\t")}`,
52 ].join("\n") + "\r",
53 );
54
55 yield* fs.writeFileString(
56 path.join(sshDir, "known_hosts"),
57 "escaped.example.com ssh-ed25519 AAAA\nquoted.example.com ssh-ed25519 AAAA\n",
58 );
59
60 const hosts = yield* discoverSshHosts({ homeDir });
61 assert.deepEqual(
62 hosts.map((host) => [host.alias, host.hostname, host.source]),
63 [
64 ["escaped", "escaped.example.com", "ssh-config"],
65 ["quoted", "quoted.example.com", "ssh-config"],
66 ...unicodeFiles.map((_, index) => [
67 `unicode-${index}`,
68 `unicode-${index}.example.com`,
69 "ssh-config",
70 ]),
71 ],
72 );
73 }).pipe(Effect.provide(NodeServices.layer)),
74 );
75
76 it.effect("skips directories and implicit dotfile Include matches but keeps explicit ones", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const homeDir = yield* makeTempHomeDir();
81 const sshDir = path.join(homeDir, ".ssh");
82 yield* fs.makeDirectory(path.join(sshDir, "config.d", "ignored"), { recursive: true });
83 yield* fs.makeDirectory(path.join(sshDir, "explicit-directory"));
84 yield* fs.writeFileString(
85 path.join(sshDir, "config"),
86 "Include config.d/*\nInclude config.d/.literal.conf\nInclude config.d/.enabled-*.conf\nInclude explicit-directory\nHost real\n HostName real.example.com\n",
87 );
88 yield* fs.writeFileString(
89 path.join(sshDir, "config.d", "team.conf"),
90 "Host team\n HostName team.example.com\n",
91 );
92 yield* fs.writeFileString(
93 path.join(sshDir, "config.d", ".backup.conf"),
94 "Host *\n HostName obsolete.example.com\n",
95 );
96 yield* fs.writeFileString(
97 path.join(sshDir, "config.d", ".literal.conf"),
98 "Host literal\n HostName literal.example.com\n",
99 );
100 yield* fs.writeFileString(
101 path.join(sshDir, "config.d", ".enabled-one.conf"),
102 "Host enabled\n HostName enabled.example.com\n",
103 );
104 yield* fs.writeFileString(
105 path.join(sshDir, "known_hosts"),
106 "known.example.com ssh-ed25519 AAAA\n",
107 );
108
109 const hosts = yield* discoverSshHosts({ homeDir });
110 assert.deepEqual(
111 hosts.map((host) => [host.alias, host.hostname, host.source]),
112 [
113 ["enabled", "enabled.example.com", "ssh-config"],
114 ["known.example.com", "known.example.com", "known-hosts"],
115 ["literal", "literal.example.com", "ssh-config"],
116 ["real", "real.example.com", "ssh-config"],
117 ["team", "team.example.com", "ssh-config"],
118 ],
119 );
120 }).pipe(Effect.provide(NodeServices.layer)),
121 );
122
123 it.effect("discovers ssh config hosts across included files", () =>
124 Effect.gen(function* () {
125 const fs = yield* FileSystem.FileSystem;
126 const path = yield* Path.Path;
127 const homeDir = yield* makeTempHomeDir();
128 const sshDir = path.join(homeDir, ".ssh");
129 yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
130 yield* fs.makeDirectory(path.join(sshDir, "teams", "one"), { recursive: true });
131 const blockedDir = path.join(sshDir, "teams", "blocked");
132 yield* fs.makeDirectory(blockedDir);
133 yield* fs.chmod(blockedDir, 0o000);
134 yield* Effect.addFinalizer(() => fs.chmod(blockedDir, 0o755).pipe(Effect.ignore));
135 yield* fs.writeFileString(
136 path.join(sshDir, "config"),
137 [
138 "Host devbox devbox-admin",
139 " HostName 'devbox.example.com'",
140 "Host=equalsbox",
141 "Host *",
142 "Include=config.d/*.conf",
143 "Include teams/*/*.conf",
144 "",
145 ].join("\n"),
146 );
147 yield* fs.writeFileString(
148 path.join(sshDir, "teams", "one", "hosts.conf"),
149 "Host fixture\n HostName fixture.example.com\n",
150 );
151 yield* fs.writeFileString(
152 path.join(sshDir, "config.d", "team.conf"),
153 [
154 "Host staging",
155 " HostName staging.example.com",
156 "Host *",
157 " ServerAliveInterval 30",
158 "",
159 ].join("\n"),
160 );
161 yield* fs.writeFileString(
162 path.join(sshDir, "known_hosts"),
163 [
164 "devbox.example.com ssh-ed25519 AAAA",
165 "staging.example.com ssh-ed25519 AAAA",
166 "known.example.com ssh-ed25519 AAAA",
167 "|1|hashed|entry ssh-ed25519 AAAA",
168 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
169 "",
170 ].join("\n"),
171 );
172
173 const hosts = yield* discoverSshHosts({ homeDir });
174 assert.deepEqual(hosts, [
175 {
176 alias: "bastion.example.com",
177 hostname: "bastion.example.com",
178 username: null,
179 port: 2222,
180 source: "known-hosts",
181 },
182 {
183 alias: "devbox",
184 hostname: "devbox.example.com",
185 username: null,
186 port: null,
187 source: "ssh-config",
188 },
189 {
190 alias: "devbox-admin",
191 hostname: "devbox.example.com",
192 username: null,
193 port: null,
194 source: "ssh-config",
195 },
196 {
197 alias: "equalsbox",
198 hostname: "equalsbox",
199 username: null,
200 port: null,
201 source: "ssh-config",
202 },
203 {
204 alias: "fixture",
205 hostname: "fixture.example.com",
206 username: null,
207 port: null,
208 source: "ssh-config",
209 },
210 {
211 alias: "known.example.com",
212 hostname: "known.example.com",
213 username: null,
214 port: null,
215 source: "known-hosts",
216 },
217 {
218 alias: "staging",
219 hostname: "staging.example.com",
220 username: null,
221 port: null,
222 source: "ssh-config",
223 },
224 ]);
225 }).pipe(Effect.provide(NodeServices.layer)),
226 );
227
228 it.effect("retains raw targets when included hostnames are inactive or uncertain", () =>
229 Effect.gen(function* () {
230 const fs = yield* FileSystem.FileSystem;
231 const path = yield* Path.Path;
232 const homeDir = yield* makeTempHomeDir();
233 const sshDir = path.join(homeDir, ".ssh");
234 yield* fs.makeDirectory(sshDir, { recursive: true });
235 yield* fs.writeFileString(
236 path.join(sshDir, "config"),
237 [
238 "Host WORK*",
239 " HostName case-sensitive.example.com",
240 "Host work",
241 "Host dev",
242 " Include conditional.conf",
243 "Host prod",
244 "Host matched",
245 "Match originalhost matched",
246 " Include match.conf",
247 "Host matched",
248 " HostName fallback.example.com",
249 "Host bracketed",
250 " Include bracket-[ab].conf",
251 " HostName bracket-fallback.example.com",
252 "",
253 ].join("\n"),
254 );
255 yield* fs.writeFileString(
256 path.join(sshDir, "conditional.conf"),
257 "Host prod\n HostName blocked.example.com\n",
258 );
259 yield* fs.writeFileString(
260 path.join(sshDir, "match.conf"),
261 "Host matched\n HostName match.example.com\n",
262 );
263 yield* fs.writeFileString(
264 path.join(sshDir, "bracket-a.conf"),
265 "HostName bracket-real.example.com\n",
266 );
267 yield* fs.writeFileString(
268 path.join(sshDir, "known_hosts"),
269 [
270 "bracket-real.example.com ssh-ed25519 AAAA",
271 "bracket-fallback.example.com ssh-ed25519 AAAA",
272 "blocked.example.com ssh-ed25519 AAAA",
273 "case-sensitive.example.com ssh-ed25519 AAAA",
274 "match.example.com ssh-ed25519 AAAA",
275 "fallback.example.com ssh-ed25519 AAAA",
276 "",
277 ].join("\n"),
278 );
279
280 const hosts = yield* discoverSshHosts({ homeDir });
281 assert.deepEqual(
282 hosts.filter((host) => host.source === "known-hosts").map((host) => host.hostname),
283 [
284 "blocked.example.com",
285 "bracket-fallback.example.com",
286 "bracket-real.example.com",
287 "case-sensitive.example.com",
288 "fallback.example.com",
289 "match.example.com",
290 ],
291 );
292 assert.equal(hosts.find((host) => host.alias === "bracketed")?.hostname, "bracketed");
293 assert.equal(hosts.find((host) => host.alias === "prod")?.hostname, "prod");
294 assert.equal(hosts.find((host) => host.alias === "work")?.hostname, "work");
295 assert.equal(hosts.find((host) => host.alias === "matched")?.hostname, "matched");
296 }).pipe(Effect.provide(NodeServices.layer)),
297 );
298
299 it.effect("keeps each explicit known_hosts port as its own destination", () =>
300 Effect.gen(function* () {
301 const fs = yield* FileSystem.FileSystem;
302 const path = yield* Path.Path;
303 const homeDir = yield* makeTempHomeDir();
304 const sshDir = path.join(homeDir, ".ssh");
305 yield* fs.makeDirectory(sshDir);
306 yield* fs.writeFileString(
307 path.join(sshDir, "known_hosts"),
308 [
309 "[100.64.0.1]:2222 ssh-ed25519 AAAA",
310 "[100.64.0.1]:2223 ssh-ed25519 AAAA",
311 "[fd7a:115c:a1e0::1]:2222 ssh-ed25519 AAAA",
312 "",
313 ].join("\n"),
314 );
315
316 const hosts = yield* discoverSshHosts({ homeDir });
317 assert.deepEqual(
318 hosts
319 .map((host) => `${host.hostname} ${host.port}`)
320 .toSorted((left, right) => left.localeCompare(right)),
321 ["100.64.0.1 2222", "100.64.0.1 2223", "fd7a:115c:a1e0::1 2222"],
322 );
323 }).pipe(Effect.provide(NodeServices.layer)),
324 );
325
326 it.effect("keeps known_hosts ports that a configured alias for the address does not use", () =>
327 Effect.gen(function* () {
328 const fs = yield* FileSystem.FileSystem;
329 const path = yield* Path.Path;
330 const homeDir = yield* makeTempHomeDir();
331 const sshDir = path.join(homeDir, ".ssh");
332 yield* fs.makeDirectory(sshDir);
333 yield* fs.writeFileString(
334 path.join(sshDir, "known_hosts"),
335 [
336 "[192.0.2.10]:2222 ssh-ed25519 AAAA",
337 "[192.0.2.10]:2223 ssh-ed25519 AAAA",
338 "[192.0.2.20]:2222 ssh-ed25519 AAAA",
339 "[box]:2200 ssh-ed25519 AAAA",
340 "box ssh-ed25519 AAAA",
341 "192.0.2.20 ssh-ed25519 AAAA",
342 "",
343 ].join("\n"),
344 );
345 yield* fs.writeFileString(
346 path.join(sshDir, "config"),
347 [
348 "Host fixture",
349 " HostName 192.0.2.10",
350 " Port 22",
351 "Host prod",
352 " HostName 192.0.2.20",
353 " Port 2222",
354 "Host box",
355 " Port 2200",
356 "",
357 ].join("\n"),
358 );
359
360 const hosts = yield* discoverSshHosts({ homeDir });
361 assert.deepEqual(
362 hosts.map((host) => `${host.alias} ${host.port}`),
363 ["192.0.2.10 2222", "192.0.2.10 2223", "box null", "fixture null", "prod null"],
364 );
365 }).pipe(Effect.provide(NodeServices.layer)),
366 );
367
368 it.effect("parses known_hosts entries without returning hashed hosts", () =>
369 Effect.sync(() => {
370 assert.deepEqual(
371 parseKnownHosts(
372 [
373 "github.com ssh-ed25519 AAAA",
374 "gitlab.com,gitlab-alias ssh-ed25519 BBBB",
375 "|1|hashed|entry ssh-ed25519 CCCC",
376 "@cert-authority *.example.com ssh-ed25519 DDDD",
377 "[ssh.example.com]:2200 ssh-ed25519 EEEE",
378 "port.example.com:22 ssh-ed25519 HHHH",
379 "::1 ssh-ed25519 FFFF",
380 "2001:db8::1 ssh-ed25519 GGGG",
381 "",
382 ].join("\n"),
383 ),
384 [
385 { hostname: "::1", port: null },
386 { hostname: "2001:db8::1", port: null },
387 { hostname: "github.com", port: null },
388 { hostname: "gitlab-alias", port: null },
389 { hostname: "gitlab.com", port: null },
390 { hostname: "port.example.com", port: null },
391 { hostname: "ssh.example.com", port: 2200 },
392 ],
393 );
394 }),
395 );
396});