import * as plugins from './plugins.js'; import type { ISshConfigFile, ISshConfigHost, ISshConfigOptions, ISshConfigReadResult, } from './types.js'; export class SshConfig { public readonly homeDir: string; public readonly mainConfigPath: string; constructor(options: ISshConfigOptions = {}) { this.homeDir = options.homeDir ?? plugins.os.homedir(); this.mainConfigPath = this.expandHome(options.mainConfigPath ?? plugins.path.join(this.homeDir, '.ssh', 'config')); } public async read(): Promise { const files: ISshConfigFile[] = []; const hosts: ISshConfigHost[] = []; const visitedFiles = new Set(); await this.readFileRecursive(this.mainConfigPath, visitedFiles, files, hosts, true); return { mainConfigPath: this.mainConfigPath, files, hosts, }; } public async getHost(alias: string): Promise { const result = await this.read(); return result.hosts.find((host) => host.patterns.includes(alias)); } public getDisplayHosts(result: ISshConfigReadResult): ISshConfigHost[] { return result.hosts.filter((host) => host.patterns.some((pattern) => !this.patternHasWildcard(pattern))); } public getFirstOption(host: ISshConfigHost, optionName: string): string | undefined { return host.options[optionName.toLowerCase()]?.[0]; } public expandHome(filePath: string): string { if (filePath === '~') { return this.homeDir; } if (filePath.startsWith('~/')) { return plugins.path.join(this.homeDir, filePath.slice(2)); } return filePath; } private async readFileRecursive( filePath: string, visitedFiles: Set, files: ISshConfigFile[], hosts: ISshConfigHost[], required: boolean ): Promise { const resolvedPath = plugins.path.resolve(this.expandHome(filePath)); if (visitedFiles.has(resolvedPath)) { return; } visitedFiles.add(resolvedPath); let content = ''; try { content = await plugins.fs.readFile(resolvedPath, 'utf8'); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT' && !required) { return; } if (nodeError.code === 'ENOENT' && required) { files.push({ filePath: resolvedPath, lines: [] }); return; } throw error; } const lines = content.split(/\r?\n/); files.push({ filePath: resolvedPath, lines }); const includePatterns: string[] = []; this.parseHostsInFile(resolvedPath, lines, hosts, includePatterns); for (const includePattern of includePatterns) { const includeFiles = await this.expandIncludePattern(includePattern, resolvedPath); for (const includeFile of includeFiles) { await this.readFileRecursive(includeFile, visitedFiles, files, hosts, false); } } } private parseHostsInFile( filePath: string, lines: string[], hosts: ISshConfigHost[], includePatterns: string[] ): void { let currentHost: ISshConfigHost | undefined; const closeCurrentHost = (endLine: number) => { if (!currentHost) { return; } currentHost.endLine = endLine; currentHost.rawLines = lines.slice(currentHost.startLine - 1, endLine); hosts.push(currentHost); currentHost = undefined; }; lines.forEach((rawLine, index) => { const lineNumber = index + 1; const withoutComment = this.stripInlineComment(rawLine).trim(); if (!withoutComment) { return; } const directiveMatch = withoutComment.match(/^([^\s]+)\s+(.*)$/); if (!directiveMatch) { return; } const directive = directiveMatch[1].toLowerCase(); const value = directiveMatch[2].trim(); if (directive === 'include') { includePatterns.push(...this.shellSplit(value)); return; } if (directive === 'host' || directive === 'match') { closeCurrentHost(lineNumber - 1); } if (directive === 'host') { const previousLine = lines[index - 1] ?? ''; const dapBeginMatch = previousLine.match(/^\s*#\s*dap:begin\s+(.+)\s*$/i); currentHost = { patterns: this.shellSplit(value), filePath, startLine: lineNumber, endLine: lineNumber, options: {}, rawLines: [], dapManaged: Boolean(dapBeginMatch), dapManagedName: dapBeginMatch?.[1]?.trim(), }; return; } if (currentHost) { const normalizedDirective = directive.toLowerCase(); currentHost.options[normalizedDirective] = currentHost.options[normalizedDirective] ?? []; currentHost.options[normalizedDirective].push(value); } }); closeCurrentHost(lines.length); } private stripInlineComment(line: string): string { let quote: string | undefined; let escaped = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (escaped) { escaped = false; continue; } if (char === '\\') { escaped = true; continue; } if ((char === '"' || char === "'") && !quote) { quote = char; continue; } if (char === quote) { quote = undefined; continue; } if (char === '#' && !quote && (i === 0 || /\s/.test(line[i - 1]))) { return line.slice(0, i); } } return line; } public shellSplit(input: string): string[] { const tokens: string[] = []; let current = ''; let quote: string | undefined; let escaped = false; for (const char of input) { if (escaped) { current += char; escaped = false; continue; } if (char === '\\') { escaped = true; continue; } if ((char === '"' || char === "'") && !quote) { quote = char; continue; } if (char === quote) { quote = undefined; continue; } if (/\s/.test(char) && !quote) { if (current) { tokens.push(current); current = ''; } continue; } current += char; } if (current) { tokens.push(current); } return tokens; } private async expandIncludePattern(pattern: string, includingFilePath: string): Promise { const expandedPattern = this.expandHome(pattern); const absolutePattern = plugins.path.isAbsolute(expandedPattern) ? expandedPattern : plugins.path.join(plugins.path.dirname(includingFilePath), expandedPattern); return this.expandGlob(absolutePattern); } private async expandGlob(pattern: string): Promise { const absolutePattern = plugins.path.resolve(pattern); if (!this.pathHasWildcard(absolutePattern)) { return plugins.fsSync.existsSync(absolutePattern) ? [absolutePattern] : []; } const parsedPath = plugins.path.parse(absolutePattern); const segments = absolutePattern.slice(parsedPath.root.length).split(plugins.path.sep).filter(Boolean); const matches: string[] = []; const walk = async (currentPath: string, segmentIndex: number): Promise => { if (segmentIndex >= segments.length) { try { const stat = await plugins.fs.stat(currentPath); if (stat.isFile()) { matches.push(currentPath); } } catch { return; } return; } const segment = segments[segmentIndex]; if (!this.pathHasWildcard(segment)) { await walk(plugins.path.join(currentPath, segment), segmentIndex + 1); return; } let entries: plugins.fsSync.Dirent[]; try { entries = await plugins.fs.readdir(currentPath, { withFileTypes: true }); } catch { return; } const matcher = this.globSegmentToRegExp(segment); for (const entry of entries) { if (!matcher.test(entry.name)) { continue; } const nextPath = plugins.path.join(currentPath, entry.name); if (segmentIndex === segments.length - 1 || entry.isDirectory()) { await walk(nextPath, segmentIndex + 1); } } }; await walk(parsedPath.root, 0); return matches.sort(); } private globSegmentToRegExp(segment: string): RegExp { const source = segment .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '[^/]*') .replace(/\?/g, '[^/]'); return new RegExp(`^${source}$`); } private patternHasWildcard(pattern: string): boolean { return /[*?]/.test(pattern); } private pathHasWildcard(filePath: string): boolean { return /[*?]/.test(filePath); } }