1import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7
8import {
9 discoverSshHosts,
10 parseKnownHostsHostnames,
11 resolveSshConfigIncludePattern,
12} from "./config.ts";
13
14function makeTempHomeDir() {
15 return Effect.gen(function* () {
16 const fs = yield* FileSystem.FileSystem;
17 return yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-test-" });
18 });
19}
20
21describe("ssh config", () => {
22 it.effect("discovers ssh config hosts across included files", () =>
23 Effect.gen(function* () {
24 const fs = yield* FileSystem.FileSystem;
25 const path = yield* Path.Path;
26 const homeDir = yield* makeTempHomeDir();
27 const sshDir = path.join(homeDir, ".ssh");
28 yield* fs.makeDirectory(path.join(sshDir, "config.d"), { recursive: true });
29 yield* fs.writeFileString(
30 path.join(sshDir, "config"),
31 [
32 "Host devbox",
33 " HostName devbox.example.com",
34 "Host=equalsbox",
35 "Include=config.d/*.conf",
36 "",
37 ].join("\n"),
38 );
39 yield* fs.writeFileString(
40 path.join(sshDir, "config.d", "team.conf"),
41 [
42 "Host staging",
43 " HostName staging.example.com",
44 "Host *",
45 " ServerAliveInterval 30",
46 "",
47 ].join("\n"),
48 );
49 yield* fs.writeFileString(
50 path.join(sshDir, "known_hosts"),
51 [
52 "known.example.com ssh-ed25519 AAAA",
53 "|1|hashed|entry ssh-ed25519 AAAA",
54 "[bastion.example.com]:2222 ssh-ed25519 AAAA",
55 "",
56 ].join("\n"),
57 );
58
59 const hosts = yield* discoverSshHosts({ homeDir });
60 assert.deepEqual(hosts, [
61 {
62 alias: "bastion.example.com",
63 hostname: "bastion.example.com",
64 username: null,
65 port: null,
66 source: "known-hosts",
67 },
68 {
69 alias: "devbox",
70 hostname: "devbox",
71 username: null,
72 port: null,
73 source: "ssh-config",
74 },
75 {
76 alias: "equalsbox",
77 hostname: "equalsbox",
78 username: null,
79 port: null,
80 source: "ssh-config",
81 },
82 {
83 alias: "known.example.com",
84 hostname: "known.example.com",
85 username: null,
86 port: null,
87 source: "known-hosts",
88 },
89 {
90 alias: "staging",
91 hostname: "staging",
92 username: null,
93 port: null,
94 source: "ssh-config",
95 },
96 ]);
97 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
98 );
99
100 it.effect("parses known_hosts entries without returning hashed hosts", () =>
101 Effect.sync(() => {
102 assert.deepEqual(
103 parseKnownHostsHostnames(
104 [
105 "github.com ssh-ed25519 AAAA",
106 "gitlab.com,gitlab-alias ssh-ed25519 BBBB",
107 "|1|hashed|entry ssh-ed25519 CCCC",
108 "@cert-authority *.example.com ssh-ed25519 DDDD",
109 "[ssh.example.com]:2200 ssh-ed25519 EEEE",
110 "port.example.com:22 ssh-ed25519 HHHH",
111 "::1 ssh-ed25519 FFFF",
112 "2001:db8::1 ssh-ed25519 GGGG",
113 "",
114 ].join("\n"),
115 ),
116 [
117 "::1",
118 "2001:db8::1",
119 "github.com",
120 "gitlab-alias",
121 "gitlab.com",
122 "port.example.com",
123 "ssh.example.com",
124 ],
125 );
126 }),
127 );
128
129 it.effect("expands tilde-prefixed ssh config include patterns", () =>
130 Effect.gen(function* () {
131 const pattern = yield* resolveSshConfigIncludePattern(
132 "~/.ssh/config.d/*.conf",
133 "/tmp/project",
134 "/tmp/home",
135 );
136 assert.equal(pattern, "/tmp/home/.ssh/config.d/*.conf");
137 }).pipe(Effect.provide(NodePath.layerPosix)),
138 );
139});