1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9
10import { SshHostDiscoveryError } from "./errors.ts";
11
12const NO_HOSTS: ReadonlyArray<string> = [] as const;
13
14function stripInlineComment(line: string): string {
15 const hashIndex = line.indexOf("#");
16 return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim();
17}
18
19function splitDirectiveArgs(value: string): ReadonlyArray<string> {
20 const args: Array<string> = [];
21 for (const rawEntry of value
22 .replace(/=(?!=)/gu, " ")
23 .trim()
24 .split(/\s+/u)) {
25 const entry = rawEntry.trim();
26 if (entry.length > 0) {
27 args.push(entry);
28 }
29 }
30 return args;
31}
32
33function expandHomePath(input: string, homeDir: string): string {
34 return input.replace(/^~(?=$|\/|\\)/u, homeDir);
35}
36
37export const resolveSshConfigIncludePattern = Effect.fnUntraced(function* (
38 includePattern: string,
39 _directory: string,
40 homeDir: string,
41) {
42 const path = yield* Path.Path;
43 const expandedPattern = expandHomePath(includePattern, homeDir);
44 return path.isAbsolute(expandedPattern)
45 ? expandedPattern
46 : path.resolve(path.join(homeDir, ".ssh"), expandedPattern);
47});
48
49function hasSshPattern(value: string): boolean {
50 return value.includes("*") || value.includes("?") || value.startsWith("!");
51}
52
53function escapeRegex(value: string): string {
54 return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
55}
56
57function globToRegExp(pattern: string): RegExp {
58 return new RegExp(
59 `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`,
60 "u",
61 );
62}
63
64const expandGlob = Effect.fnUntraced(function* (pattern: string) {
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 if (!pattern.includes("*") && !pattern.includes("?")) {
68 return (yield* fs.exists(pattern)) ? [pattern] : NO_HOSTS;
69 }
70
71 const directory = path.dirname(pattern);
72 const basePattern = path.basename(pattern);
73 if (!(yield* fs.exists(directory))) {
74 return NO_HOSTS;
75 }
76
77 const matcher = globToRegExp(basePattern);
78 const entries = yield* fs.readDirectory(directory);
79 const matchedPaths: string[] = [];
80 for (const entry of entries) {
81 if (!matcher.test(entry)) {
82 continue;
83 }
84 const entryPath = path.join(directory, entry);
85 if (yield* fs.exists(entryPath)) {
86 matchedPaths.push(entryPath);
87 }
88 }
89 return matchedPaths.toSorted((left, right) => left.localeCompare(right));
90});
91
92const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
93 filePath: string,
94 visited = new Set<string>(),
95 homeDir: string,
96): Effect.fn.Return<
97 ReadonlyArray<string>,
98 PlatformError.PlatformError,
99 FileSystem.FileSystem | Path.Path
100> {
101 const fs = yield* FileSystem.FileSystem;
102 const path = yield* Path.Path;
103 const resolvedPath = path.resolve(filePath);
104 if (visited.has(resolvedPath) || !(yield* fs.exists(resolvedPath))) {
105 return NO_HOSTS;
106 }
107 visited.add(resolvedPath);
108
109 const aliases = new Set<string>();
110 const directory = path.dirname(resolvedPath);
111 const raw = yield* fs.readFileString(resolvedPath);
112
113 for (const line of raw.split(/\r?\n/u)) {
114 const stripped = stripInlineComment(line);
115 if (stripped.length === 0) {
116 continue;
117 }
118
119 const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped);
120 const normalizedDirective = directive.toLowerCase();
121 if (normalizedDirective === "include") {
122 for (const includePattern of rawArgs) {
123 const resolvedPattern = yield* resolveSshConfigIncludePattern(
124 includePattern,
125 directory,
126 homeDir,
127 );
128 const includedPaths = yield* expandGlob(resolvedPattern);
129 for (const includedPath of includedPaths) {
130 const includedAliases = yield* collectSshConfigAliasesFromFile(
131 includedPath,
132 visited,
133 homeDir,
134 );
135 for (const alias of includedAliases) {
136 aliases.add(alias);
137 }
138 }
139 }
140 continue;
141 }
142
143 if (normalizedDirective !== "host") {
144 continue;
145 }
146
147 for (const alias of rawArgs) {
148 if (alias.length === 0 || hasSshPattern(alias)) {
149 continue;
150 }
151 aliases.add(alias);
152 }
153 }
154
155 return [...aliases].toSorted((left, right) => left.localeCompare(right));
156});
157
158function normalizeKnownHostsHostname(rawHost: string): string {
159 const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost);
160 if (bracketMatch?.[1]) {
161 return bracketMatch[1];
162 }
163
164 if (!rawHost.includes(":")) {
165 return rawHost;
166 }
167
168 const firstColonIndex = rawHost.indexOf(":");
169 const lastColonIndex = rawHost.lastIndexOf(":");
170 return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost;
171}
172
173export function parseKnownHostsHostnames(raw: string): ReadonlyArray<string> {
174 const hostnames = new Set<string>();
175
176 for (const line of raw.split(/\r?\n/u)) {
177 const trimmed = line.trim();
178 if (trimmed.length === 0 || trimmed.startsWith("#")) {
179 continue;
180 }
181
182 const withoutMarker = trimmed.startsWith("@")
183 ? trimmed.split(/\s+/u).slice(1).join(" ")
184 : trimmed;
185 const [hostField = ""] = withoutMarker.split(/\s+/u);
186 if (hostField.length === 0 || hostField.startsWith("|")) {
187 continue;
188 }
189
190 for (const rawHost of hostField.split(",")) {
191 const host = normalizeKnownHostsHostname(rawHost).trim();
192 if (host.length === 0 || hasSshPattern(host)) {
193 continue;
194 }
195 hostnames.add(host);
196 }
197 }
198
199 return [...hostnames].toSorted((left, right) => left.localeCompare(right));
200}
201
202const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
203 const fs = yield* FileSystem.FileSystem;
204 if (!(yield* fs.exists(filePath))) {
205 return NO_HOSTS;
206 }
207 return parseKnownHostsHostnames(yield* fs.readFileString(filePath));
208});
209
210export const discoverSshHosts = Effect.fnUntraced(
211 function* (input: { readonly homeDir?: string }) {
212 const path = yield* Path.Path;
213 const env = yield* Config.all({
214 home: Config.String("HOME").pipe(Config.option),
215 userProfile: Config.String("USERPROFILE").pipe(Config.option),
216 });
217 const homeDir =
218 input?.homeDir ??
219 Option.getOrUndefined(env.home) ??
220 Option.getOrUndefined(env.userProfile) ??
221 "";
222 if (homeDir.trim().length === 0) {
223 return [];
224 }
225
226 const sshDirectory = path.join(homeDir, ".ssh");
227 const configAliases = yield* collectSshConfigAliasesFromFile(
228 path.join(sshDirectory, "config"),
229 new Set<string>(),
230 homeDir,
231 );
232 const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
233 const discovered = new Map<string, DesktopDiscoveredSshHost>();
234
235 for (const alias of configAliases) {
236 discovered.set(alias, {
237 alias,
238 hostname: alias,
239 username: null,
240 port: null,
241 source: "ssh-config",
242 });
243 }
244
245 for (const hostname of knownHosts) {
246 if (discovered.has(hostname)) {
247 continue;
248 }
249 discovered.set(hostname, {
250 alias: hostname,
251 hostname,
252 username: null,
253 port: null,
254 source: "known-hosts",
255 });
256 }
257
258 return [...discovered.values()].toSorted((left, right) =>
259 left.alias.localeCompare(right.alias),
260 );
261 },
262 Effect.mapError(
263 (cause) =>
264 new SshHostDiscoveryError({
265 message: "Failed to discover SSH hosts.",
266 cause,
267 }),
268 ),
269);