diff --git a/src/main/ssh/ssh-config-include-expander.ts b/src/main/ssh/ssh-config-include-expander.ts new file mode 100644 index 000000000..b26b32b52 --- /dev/null +++ b/src/main/ssh/ssh-config-include-expander.ts @@ -0,0 +1,343 @@ +import { existsSync, globSync, readFileSync, realpathSync, statSync } from 'fs' +import { homedir, hostname, userInfo } from 'os' +import { posix, win32 } from 'path' + +type PathApi = typeof posix | typeof win32 + +type IncludeExpansionContext = { + cache: Map + home: string + pathApi: PathApi + rootDir: string + shortHostname: string + uid?: string + username: string +} + +const MAX_INCLUDE_GLOB_MATCHES = 256 +const MAX_INCLUDE_FILE_BYTES = 1024 * 1024 +const TARGET_DEPENDENT_INCLUDE_TOKENS = new Set(['h', 'n', 'p', 'r', 'j', 'k', 'C']) + +export function expandSshConfigIncludes(configPath: string): string { + const home = homedir() + const pathApi = getPathApi(configPath) + const currentUser = getCurrentUser() + const localHostname = hostname() + + const context: IncludeExpansionContext = { + cache: new Map(), + home, + pathApi, + rootDir: pathApi.dirname(configPath), + shortHostname: localHostname.split('.')[0] || localHostname, + uid: getCurrentUid(), + username: currentUser + } + + return expandSshConfigFile(configPath, context, []).join('\n') +} + +function expandSshConfigFile( + filePath: string, + context: IncludeExpansionContext, + activeStack: string[] +): string[] { + const canonicalPath = getCanonicalPath(filePath) + if (!canonicalPath || activeStack.includes(canonicalPath)) { + return [] + } + + const rawContent = readCachedFile(canonicalPath, context) + if (rawContent === null) { + return [] + } + + const expandedLines: string[] = [] + const nextStack = [...activeStack, canonicalPath] + + for (const line of rawContent.split(/\r?\n/)) { + const includeArgs = parseIncludeDirective(line) + if (!includeArgs) { + expandedLines.push(line) + continue + } + + for (const includeArg of includeArgs) { + for (const matchedPath of resolveIncludePaths(includeArg, context)) { + expandedLines.push(...expandSshConfigFile(matchedPath, context, nextStack)) + } + } + } + + return expandedLines +} + +function readCachedFile(filePath: string, context: IncludeExpansionContext): string | null { + const cached = context.cache.get(filePath) + if (cached !== undefined) { + return cached + } + + if (!isReadableRegularFile(filePath)) { + return null + } + + try { + const content = readFileSync(filePath, 'utf-8') + context.cache.set(filePath, content) + return content + } catch { + return null + } +} + +function parseIncludeDirective(line: string): string[] | null { + const trimmed = line.trimStart() + if (!trimmed || trimmed.startsWith('#')) { + return null + } + + const match = trimmed.match(/^([^=\s]+)(?:\s*=\s*|\s+)(.*)$/) + if (!match || match[1].toLowerCase() !== 'include') { + return null + } + + const args = splitQuotedArguments(match[2]) + return args.length > 0 ? args : null +} + +function splitQuotedArguments(input: string): string[] { + const args: string[] = [] + let current = '' + let inQuotes = false + + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + + if (inQuotes && char === '\\' && input[i + 1] === '"') { + current += '"' + i += 1 + continue + } + + if (char === '"') { + inQuotes = !inQuotes + continue + } + + if (!inQuotes && char === '#') { + break + } + + if (!inQuotes && /\s/.test(char)) { + if (current) { + args.push(current) + current = '' + } + continue + } + + current += char + } + + if (current) { + args.push(current) + } + + return args +} + +function resolveIncludePaths(pattern: string, context: IncludeExpansionContext): string[] { + const withEnv = expandEnvironmentVariables(pattern) + if (withEnv === null) { + return [] + } + + const withTokens = expandIncludeTokens(withEnv, context) + if (withTokens === null) { + return [] + } + + const absolutePattern = resolveIncludePatternPath(withTokens, context) + if (hasGlobPattern(absolutePattern)) { + try { + const matches = globSync(absolutePattern).sort((left, right) => left.localeCompare(right)) + if (matches.length > MAX_INCLUDE_GLOB_MATCHES) { + console.warn( + `[ssh] Include pattern "${absolutePattern}" matched ${matches.length} files; processing first ${MAX_INCLUDE_GLOB_MATCHES}` + ) + return matches.slice(0, MAX_INCLUDE_GLOB_MATCHES) + } + return matches + } catch { + return [] + } + } + + return existsSync(absolutePattern) ? [absolutePattern] : [] +} + +function expandEnvironmentVariables(input: string): string | null { + let missing = false + const expanded = input.replaceAll(/\$\{([^}]+)\}/g, (_, name: string) => { + const value = process.env[name] + if (value === undefined) { + missing = true + return '' + } + return value + }) + + return missing ? null : expanded +} + +function expandIncludeTokens(input: string, context: IncludeExpansionContext): string | null { + let output = '' + + for (let i = 0; i < input.length; i += 1) { + const char = input[i] + if (char !== '%') { + output += char + continue + } + + const token = input[i + 1] + if (!token) { + output += char + continue + } + + if (token === '%') { + output += '%' + i += 1 + continue + } + + if (TARGET_DEPENDENT_INCLUDE_TOKENS.has(token)) { + return null + } + + if (token === 'd') { + output += context.home + i += 1 + continue + } + + if (token === 'u') { + output += context.username + i += 1 + continue + } + + if (token === 'i') { + if (!context.uid) { + return null + } + output += context.uid + i += 1 + continue + } + + if (token === 'l') { + output += hostname() + i += 1 + continue + } + + if (token === 'L') { + output += context.shortHostname + i += 1 + continue + } + + output += `%${token}` + i += 1 + } + + return output +} + +function resolveIncludePatternPath(input: string, context: IncludeExpansionContext): string { + const pathApi = context.pathApi + if (input === '~') { + return context.home + } + if (input.startsWith('~/') || input.startsWith('~\\')) { + return pathApi.join(context.home, input.slice(2)) + } + + if (pathApi.isAbsolute(input)) { + return pathApi.normalize(input) + } + + return pathApi.normalize(pathApi.join(context.rootDir, input)) +} + +function hasGlobPattern(input: string): boolean { + return /[*?[]/.test(input) +} + +function getCanonicalPath(filePath: string): string | null { + try { + return realpathSync.native(filePath) + } catch { + return null + } +} + +function isReadableRegularFile(filePath: string): boolean { + try { + const stats = statSync(filePath) + if (!stats.isFile()) { + console.warn(`[ssh] Skipping SSH config include "${filePath}": not a regular file`) + return false + } + if (stats.size > MAX_INCLUDE_FILE_BYTES) { + console.warn( + `[ssh] Skipping SSH config include "${filePath}": size ${stats.size} exceeds ${MAX_INCLUDE_FILE_BYTES} bytes` + ) + return false + } + return true + } catch { + return false + } +} + +function getCurrentUid(): string | undefined { + try { + const info = userInfo() + if (typeof info.uid === 'number' && info.uid >= 0) { + return String(info.uid) + } + } catch { + return undefined + } + + if (typeof process.getuid === 'function') { + try { + return String(process.getuid()) + } catch { + return undefined + } + } + + return undefined +} + +function getCurrentUser(): string { + try { + const info = userInfo() + if (info.username) { + return info.username + } + } catch { + // Fall back to environment variables below. + } + + return process.env.USER ?? process.env.USERNAME ?? '' +} + +function getPathApi(filePath: string): PathApi { + return /^[a-zA-Z]:[\\/]/.test(filePath) || filePath.startsWith('\\\\') ? win32 : posix +} diff --git a/src/main/ssh/ssh-config-loader-regression.test.ts b/src/main/ssh/ssh-config-loader-regression.test.ts new file mode 100644 index 000000000..85899df25 --- /dev/null +++ b/src/main/ssh/ssh-config-loader-regression.test.ts @@ -0,0 +1,269 @@ +import type * as FsModule from 'node:fs' +import type * as OsModule from 'node:os' +import { win32 } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(() => { + vi.restoreAllMocks() + vi.resetModules() + vi.doUnmock('fs') + vi.doUnmock('os') +}) + +function normalizeWin(value: string): string { + return win32.normalize(value.replaceAll('/', '\\')) +} + +async function mockOs( + home: string, + username = 'testuser', + uid = 1001, + hostname = 'host.example.com' +) { + vi.doMock('os', async () => { + const actual = await vi.importActual('os') + return { + ...actual, + homedir: () => home, + hostname: () => hostname, + userInfo: () => ({ username, uid }) + } + }) +} + +async function loadUserSshConfig() { + const mod = await import('./ssh-config-parser') + return mod.loadUserSshConfig() +} + +describe('loadUserSshConfig regressions', () => { + it('supports Windows-style home paths and include separators', async () => { + const files = new Map([ + [ + normalizeWin('C:/Users/Test User/.ssh/config'), + 'Include .\\conf.d\\*.conf "C:\\Users\\Test User\\quoted configs\\team.conf" forward/slash.conf' + ], + [ + normalizeWin('C:/Users/Test User/.ssh/conf.d/zeta.conf'), + 'Host zeta\n HostName zeta.example.com\n' + ], + [ + normalizeWin('C:/Users/Test User/.ssh/conf.d/alpha.conf'), + 'Host alpha\n HostName alpha.example.com\n' + ], + [ + normalizeWin('C:/Users/Test User/quoted configs/team.conf'), + 'Host team\n HostName team.example.com\n' + ], + [ + normalizeWin('C:/Users/Test User/.ssh/forward/slash.conf'), + 'Host forward\n HostName forward.example.com\n' + ] + ]) + + await mockOs('C:\\Users\\Test User', 'TestUser', -1, 'winbox.example.com') + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: (filePath: string) => files.has(normalizeWin(filePath)), + globSync: (pattern: string) => + normalizeWin(pattern) === normalizeWin('C:/Users/Test User/.ssh/conf.d/*.conf') + ? [ + normalizeWin('C:/Users/Test User/.ssh/conf.d/alpha.conf'), + normalizeWin('C:/Users/Test User/.ssh/conf.d/zeta.conf') + ] + : [], + readFileSync: (filePath: string) => { + const content = files.get(normalizeWin(filePath)) + if (content === undefined) { + throw new Error(`ENOENT: ${filePath}`) + } + return content + }, + realpathSync: Object.assign((filePath: string) => normalizeWin(filePath), { + native: (filePath: string) => normalizeWin(filePath) + }), + statSync: (filePath: string) => { + const content = files.get(normalizeWin(filePath)) + if (content === undefined) { + throw new Error(`ENOENT: ${filePath}`) + } + return { isFile: () => true, size: content.length } + } + } + }) + + const hosts = await loadUserSshConfig() + expect(hosts.map((host) => host.host)).toEqual(['alpha', 'zeta', 'team', 'forward']) + }) + + it('preserves quoted Windows include paths with native backslashes and spaces', async () => { + const files = new Map([ + [ + normalizeWin('C:/Users/Test User/.ssh/config'), + 'Include "C:\\Users\\Test User\\quoted configs\\team.conf"' + ], + [ + normalizeWin('C:/Users/Test User/quoted configs/team.conf'), + 'Host team\n HostName team.example.com\n' + ] + ]) + + await mockOs('C:\\Users\\Test User', 'TestUser', -1, 'winbox.example.com') + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: (filePath: string) => files.has(normalizeWin(filePath)), + readFileSync: (filePath: string) => { + const content = files.get(normalizeWin(filePath)) + if (content === undefined) { + throw new Error(`ENOENT: ${filePath}`) + } + return content + }, + realpathSync: Object.assign((filePath: string) => normalizeWin(filePath), { + native: (filePath: string) => normalizeWin(filePath) + }), + statSync: (filePath: string) => { + const content = files.get(normalizeWin(filePath)) + if (content === undefined) { + throw new Error(`ENOENT: ${filePath}`) + } + return { isFile: () => true, size: content.length } + } + } + }) + + expect(await loadUserSshConfig()).toEqual([{ host: 'team', hostname: 'team.example.com' }]) + }) + + it('skips non-regular include targets without reading them', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const home = '/home/testuser' + const configPath = `${home}/.ssh/config` + const unsafePath = `${home}/.ssh/unsafe.conf` + const safePath = `${home}/.ssh/safe.conf` + const unsafeReadSpy = vi.fn() + + await mockOs(home) + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: (filePath: string) => + filePath === configPath || filePath === unsafePath || filePath === safePath, + readFileSync: (filePath: string) => { + if (filePath === unsafePath) { + unsafeReadSpy() + throw new Error(`unexpected read: ${filePath}`) + } + if (filePath === configPath) { + return 'Include unsafe.conf safe.conf\n' + } + if (filePath === safePath) { + return 'Host safe\n HostName safe.example.com\n' + } + throw new Error(`ENOENT: ${filePath}`) + }, + realpathSync: Object.assign((filePath: string) => filePath, { + native: (filePath: string) => filePath + }), + statSync: (filePath: string) => ({ isFile: () => filePath !== unsafePath, size: 64 }) + } + }) + + expect(await loadUserSshConfig()).toEqual([{ host: 'safe', hostname: 'safe.example.com' }]) + expect(unsafeReadSpy).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping SSH config include')) + }) + + it('caps overly broad include globs and skips the remainder', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const home = '/home/testuser' + const configPath = `${home}/.ssh/config` + const includePaths = Array.from({ length: 2000 }, (_, index) => { + return `${home}/.ssh/conf.d/${String(index).padStart(4, '0')}.conf` + }) + const readPaths = new Set() + + await mockOs(home) + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: (filePath: string) => + filePath === configPath || includePaths.includes(filePath), + globSync: () => [...includePaths].reverse(), + readFileSync: (filePath: string) => { + if (filePath === configPath) { + return 'Include conf.d/*.conf\n' + } + if (includePaths.includes(filePath)) { + readPaths.add(filePath) + const alias = filePath.match(/(\d+)\.conf$/)?.[1] ?? 'unknown' + return `Host host-${alias}\n HostName ${alias}.example.com\n` + } + throw new Error(`ENOENT: ${filePath}`) + }, + realpathSync: Object.assign((filePath: string) => filePath, { + native: (filePath: string) => filePath + }), + statSync: (filePath: string) => ({ + isFile: () => filePath === configPath || includePaths.includes(filePath), + size: 64 + }) + } + }) + + const hosts = await loadUserSshConfig() + expect(hosts.length).toBeGreaterThan(0) + expect(hosts.length).toBeLessThan(includePaths.length) + expect(readPaths.has(includePaths.at(-1)!)).toBe(false) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('matched')) + }) + + it('skips oversized include files without reading them', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const home = '/home/testuser' + const configPath = `${home}/.ssh/config` + const oversizedPath = `${home}/.ssh/oversized.conf` + const safePath = `${home}/.ssh/safe.conf` + const oversizedReadSpy = vi.fn() + + await mockOs(home) + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + existsSync: (filePath: string) => + filePath === configPath || filePath === oversizedPath || filePath === safePath, + readFileSync: (filePath: string) => { + if (filePath === oversizedPath) { + oversizedReadSpy() + throw new Error(`unexpected read: ${filePath}`) + } + if (filePath === configPath) { + return 'Include oversized.conf safe.conf\n' + } + if (filePath === safePath) { + return 'Host safe\n HostName safe.example.com\n' + } + throw new Error(`ENOENT: ${filePath}`) + }, + realpathSync: Object.assign((filePath: string) => filePath, { + native: (filePath: string) => filePath + }), + statSync: (filePath: string) => ({ + isFile: () => true, + size: filePath === oversizedPath ? 2 * 1024 * 1024 : 64 + }) + } + }) + + expect(await loadUserSshConfig()).toEqual([{ host: 'safe', hostname: 'safe.example.com' }]) + expect(oversizedReadSpy).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('exceeds')) + }) +}) diff --git a/src/main/ssh/ssh-config-loader.test.ts b/src/main/ssh/ssh-config-loader.test.ts new file mode 100644 index 000000000..316a4da05 --- /dev/null +++ b/src/main/ssh/ssh-config-loader.test.ts @@ -0,0 +1,200 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as OsModule from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadUserSshConfig } from './ssh-config-parser' + +const { homedirMock, hostnameMock, userInfoMock } = vi.hoisted(() => ({ + homedirMock: vi.fn(() => '/home/testuser'), + hostnameMock: vi.fn(() => 'workstation.example.com'), + userInfoMock: vi.fn(() => ({ username: 'testuser', uid: 1001 })) +})) + +vi.mock('os', async () => { + const actual = await vi.importActual('os') + return { + ...actual, + homedir: homedirMock, + hostname: hostnameMock, + userInfo: userInfoMock + } +}) + +const originalEnv = { ...process.env } +const tempDirs: string[] = [] + +afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key] + } + } + Object.assign(process.env, originalEnv) + homedirMock.mockImplementation(() => '/home/testuser') + hostnameMock.mockImplementation(() => 'workstation.example.com') + userInfoMock.mockImplementation(() => ({ username: 'testuser', uid: 1001 })) + + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } +}) + +function makeHome(prefix = 'orca-ssh-config-'): string { + const home = mkdtempSync(join(tmpdir(), prefix)) + tempDirs.push(home) + homedirMock.mockReturnValue(home) + mkdirSync(join(home, '.ssh'), { recursive: true }) + return home +} + +function writeFile(root: string, relativePath: string, content: string): string { + const fullPath = join(root, relativePath) + mkdirSync(dirname(fullPath), { recursive: true }) + writeFileSync(fullPath, content, 'utf-8') + return fullPath +} + +describe('loadUserSshConfig', () => { + it('loads hosts from a single included file', () => { + const home = makeHome() + writeFile(home, '.ssh/config', 'Include included.conf\n') + writeFile( + home, + '.ssh/included.conf', + 'Host included\n HostName included.example.com\n User deploy\n' + ) + + expect(loadUserSshConfig()).toEqual([ + { + host: 'included', + hostname: 'included.example.com', + user: 'deploy' + } + ]) + }) + + it('supports OrbStack includes, Include= syntax, quoted paths, and multiple pathnames', () => { + const home = makeHome() + writeFile( + home, + '.ssh/config', + [ + 'Include ~/.orbstack/ssh/config', + 'Include=extras/dev.conf "quoted configs/team ssh.conf"' + ].join('\n') + ) + writeFile(home, '.orbstack/ssh/config', 'Host orb\n HostName 100.100.100.1\n') + writeFile(home, '.ssh/extras/dev.conf', 'Host dev\n HostName dev.example.com\n') + writeFile(home, '.ssh/quoted configs/team ssh.conf', 'Host team\n HostName team.example.com\n') + + expect(loadUserSshConfig().map((host) => host.host)).toEqual(['orb', 'dev', 'team']) + }) + + it('expands glob includes in lexical order', () => { + const home = makeHome() + writeFile(home, '.ssh/config', 'Include conf.d/*.conf\n') + writeFile(home, '.ssh/conf.d/20-second.conf', 'Host second\n HostName second.example.com\n') + writeFile(home, '.ssh/conf.d/10-first.conf', 'Host first\n HostName first.example.com\n') + + expect(loadUserSshConfig().map((host) => host.host)).toEqual(['first', 'second']) + }) + + it('supports relative includes, ${VAR}, and local % tokens', () => { + const home = makeHome() + process.env.ORCA_SSH_INCLUDE = 'from-env.conf' + writeFile( + home, + '.ssh/config', + [ + 'Include relative.conf ${ORCA_SSH_INCLUDE}', + 'Include %d/.ssh/from-home.conf', + 'Include %u/%i.conf', + 'Include %%literal.conf' + ].join('\n') + ) + writeFile(home, '.ssh/relative.conf', 'Host relative\n HostName relative.example.com\n') + writeFile(home, '.ssh/from-env.conf', 'Host env\n HostName env.example.com\n') + writeFile(home, '.ssh/from-home.conf', 'Host from-home\n HostName home.example.com\n') + writeFile(home, '.ssh/testuser/1001.conf', 'Host by-user-id\n HostName token.example.com\n') + writeFile(home, '.ssh/%literal.conf', 'Host literal\n HostName literal.example.com\n') + + expect(loadUserSshConfig().map((host) => host.host)).toEqual([ + 'relative', + 'env', + 'from-home', + 'by-user-id', + 'literal' + ]) + }) + + it('expands Include directives inside Host and Match blocks', () => { + const home = makeHome() + writeFile( + home, + '.ssh/config', + [ + 'Host base', + ' HostName base.example.com', + ' Include nested/inside-host.conf', + 'Match host *.internal', + ' Include nested/inside-match.conf', + 'Host after', + ' HostName after.example.com' + ].join('\n') + ) + writeFile(home, '.ssh/nested/inside-host.conf', 'Host inner\n HostName inner.example.com\n') + writeFile( + home, + '.ssh/nested/inside-match.conf', + 'Host matched\n HostName matched.example.com\n' + ) + + expect(loadUserSshConfig().map((host) => host.host)).toEqual([ + 'base', + 'inner', + 'matched', + 'after' + ]) + }) + + it('ignores unset env includes and target-dependent tokens', () => { + const home = makeHome() + writeFile( + home, + '.ssh/config', + ['Include ${MISSING_INCLUDE}', 'Include %h/skipped.conf', 'Include valid.conf'].join('\n') + ) + writeFile(home, '.ssh/valid.conf', 'Host valid\n HostName valid.example.com\n') + + expect(loadUserSshConfig().map((host) => host.host)).toEqual(['valid']) + }) + + it('terminates recursive includes and re-evaluates repeated includes', () => { + const home = makeHome() + writeFile(home, '.ssh/config', 'Include shared.conf shared.conf recursive.conf\n') + writeFile(home, '.ssh/shared.conf', 'Host shared\n HostName shared.example.com\n') + writeFile( + home, + '.ssh/recursive.conf', + 'Include nested.conf\nHost recursive\n HostName recursive.example.com\n' + ) + writeFile( + home, + '.ssh/nested.conf', + 'Include recursive.conf\nHost nested\n HostName nested.example.com\n' + ) + + expect(loadUserSshConfig().map((host) => host.host)).toEqual([ + 'shared', + 'shared', + 'nested', + 'recursive' + ]) + }) + + it('returns an empty array when the user config does not exist', () => { + makeHome() + expect(loadUserSshConfig()).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-config-parser-host-patterns.test.ts b/src/main/ssh/ssh-config-parser-host-patterns.test.ts new file mode 100644 index 000000000..ffbe0e0d4 --- /dev/null +++ b/src/main/ssh/ssh-config-parser-host-patterns.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { parseSshConfig } from './ssh-config-parser' + +describe('parseSshConfig host pattern filtering', () => { + it('ignores negated aliases on mixed Host lines', () => { + const config = ` +Host prod !prod-admin + HostName prod.example.com +` + expect(parseSshConfig(config)).toEqual([{ host: 'prod', hostname: 'prod.example.com' }]) + }) + + it('imports only literal positive aliases from mixed wildcard and negated patterns', () => { + const config = ` +Host !legacy *.corp prod + HostName prod.example.com +` + expect(parseSshConfig(config)).toEqual([{ host: 'prod', hostname: 'prod.example.com' }]) + }) + + it('ignores inline comments on mixed Host lines', () => { + const config = ` +Host prod stage # shared production aliases + HostName prod.example.com +` + expect(parseSshConfig(config)).toEqual([ + { host: 'prod', hostname: 'prod.example.com' }, + { host: 'stage', hostname: 'prod.example.com' } + ]) + }) + + it('skips Host entries containing only wildcard and negated patterns', () => { + const config = ` +Host !legacy *.corp ?? + HostName ignored.example.com +` + expect(parseSshConfig(config)).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index 09dc76dab..72fe263c2 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -139,14 +139,16 @@ Host other expect(parseSshConfig('')).toEqual([]) }) - it('uses first pattern from multi-pattern Host line', () => { + it('creates one parsed host per concrete alias on a multi-pattern Host line', () => { const config = ` -Host staging stage +Host staging stage *.example.com HostName staging.example.com ` const hosts = parseSshConfig(config) - expect(hosts).toHaveLength(1) - expect(hosts[0].host).toBe('staging') + expect(hosts).toEqual([ + { host: 'staging', hostname: 'staging.example.com' }, + { host: 'stage', hostname: 'staging.example.com' } + ]) }) it('defaults port to 22 for invalid port values', () => { @@ -211,6 +213,21 @@ describe('sshConfigHostsToTargets', () => { expect(targets[0].proxyCommand).toBe('ssh -W %h:%p bastion') expect(targets[0].jumpHost).toBe('bastion.example.com') }) + + it('imports duplicate aliases only once and keeps the first concrete host', () => { + const hosts = [ + { host: 'dup', hostname: 'first.example.com', user: 'first' }, + { host: 'dup', hostname: 'second.example.com', user: 'second' } + ] + const targets = sshConfigHostsToTargets(hosts, new Set()) + + expect(targets).toHaveLength(1) + expect(targets[0]).toMatchObject({ + label: 'dup', + host: 'first.example.com', + username: 'first' + }) + }) }) // ── parseSshGOutput ────────────────────────────────────────────────── @@ -322,5 +339,3 @@ describe('parseSshGOutput', () => { expect(result.identityFile).toEqual(['/home/testuser/custom_key']) }) }) - -// Why: resolveWithSshG tests are in ssh-config-resolver.test.ts (max-lines). diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 242352bdb..048c6a9c5 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -1,8 +1,9 @@ -import { readFileSync, existsSync } from 'fs' +import { existsSync } from 'fs' import { execFile } from 'child_process' import { join } from 'path' import { homedir } from 'os' import type { SshTarget } from '../../shared/ssh-types' +import { expandSshConfigIncludes } from './ssh-config-include-expander' export type SshConfigHost = { host: string @@ -22,7 +23,7 @@ export type SshConfigHost = { */ export function parseSshConfig(content: string): SshConfigHost[] { const hosts: SshConfigHost[] = [] - let current: SshConfigHost | null = null + let current: SshConfigHost[] = [] for (const rawLine of content.split('\n')) { const line = rawLine.trim() @@ -40,66 +41,126 @@ export function parseSshConfig(content: string): SshConfigHost[] { const value = rawValue.trim() if (key === 'host') { - if (current) { - hosts.push(current) + if (current.length > 0) { + hosts.push(...current) } - // Skip wildcard-only entries (e.g. "Host *" or "Host *.*") - const patterns = value.split(/\s+/) - const hasConcretePattern = patterns.some((p) => !p.includes('*') && !p.includes('?')) - if (!hasConcretePattern) { - current = null + const patterns = splitHostPatterns(value) + const concretePatterns = patterns.filter( + (pattern) => !pattern.startsWith('!') && !pattern.includes('*') && !pattern.includes('?') + ) + if (concretePatterns.length === 0) { + current = [] continue } - current = { host: patterns[0] } + current = concretePatterns.map((pattern) => ({ host: pattern })) continue } if (key === 'match') { - // Match blocks are complex conditionals — push current and skip - if (current) { - hosts.push(current) + if (current.length > 0) { + hosts.push(...current) } - current = null + current = [] continue } - if (!current) { + if (current.length === 0) { continue } switch (key) { case 'hostname': - current.hostname = value + for (const host of current) { + host.hostname = value + } break case 'port': - current.port = parseInt(value, 10) || 22 + for (const host of current) { + host.port = parseInt(value, 10) || 22 + } break case 'user': - current.user = value + for (const host of current) { + host.user = value + } break case 'identityfile': - current.identityFile = resolveHomePath(value) + for (const host of current) { + host.identityFile = resolveHomePath(value) + } break case 'proxycommand': - current.proxyCommand = value + for (const host of current) { + host.proxyCommand = value + } break case 'proxyusefdpass': - current.proxyUseFdpass = value.toLowerCase() === 'yes' + for (const host of current) { + host.proxyUseFdpass = value.toLowerCase() === 'yes' + } break case 'proxyjump': - current.proxyJump = value + for (const host of current) { + host.proxyJump = value + } break } } - if (current) { - hosts.push(current) + if (current.length > 0) { + hosts.push(...current) } return hosts } +function splitHostPatterns(input: string): string[] { + const patterns: string[] = [] + let current = '' + let inQuotes = false + let escaped = false + + for (const char of input) { + if (escaped) { + current += char + escaped = false + continue + } + + if (inQuotes && char === '\\') { + escaped = true + continue + } + + if (char === '"') { + inQuotes = !inQuotes + continue + } + + // Why: multi-alias import must not turn OpenSSH inline comments into targets. + if (!inQuotes && char === '#') { + break + } + + if (!inQuotes && /\s/.test(char)) { + if (current) { + patterns.push(current) + current = '' + } + continue + } + + current += char + } + + if (current) { + patterns.push(current) + } + + return patterns +} + function resolveHomePath(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return join(homedir(), filepath.slice(1)) @@ -115,7 +176,7 @@ export function loadUserSshConfig(): SshConfigHost[] { } try { - const content = readFileSync(configPath, 'utf-8') + const content = expandSshConfigIncludes(configPath) return parseSshConfig(content) } catch { console.warn(`[ssh] Failed to read SSH config at ${configPath}`) @@ -129,15 +190,16 @@ export function sshConfigHostsToTargets( existingTargetHosts: Set ): SshTarget[] { const targets: SshTarget[] = [] + const seenLabels = new Set(existingTargetHosts) for (const entry of hosts) { const effectiveHost = entry.hostname || entry.host const label = entry.host - // Skip if already imported (match on label, which is the Host alias) - if (existingTargetHosts.has(label)) { + if (seenLabels.has(label)) { continue } + seenLabels.add(label) targets.push({ id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, @@ -154,7 +216,6 @@ export function sshConfigHostsToTargets( return targets } - // ── ssh -G config resolution ────────────────────────────────────────── export type SshResolvedConfig = {