Support OpenSSH Include expansion for SSH config imports

Closes #2647
This commit is contained in:
Phil Denhoff 2026-05-23 00:27:20 -07:00 committed by GitHub
parent 59ac13dc53
commit 2dcaa78960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 961 additions and 34 deletions

View File

@ -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<string, string>
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
}

View File

@ -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<typeof OsModule>('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<string, string>([
[
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<typeof FsModule>('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<string, string>([
[
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<typeof FsModule>('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<typeof FsModule>('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<string>()
await mockOs(home)
vi.doMock('fs', async () => {
const actual = await vi.importActual<typeof FsModule>('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<typeof FsModule>('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'))
})
})

View File

@ -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<typeof OsModule>('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([])
})
})

View File

@ -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([])
})
})

View File

@ -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).

View File

@ -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<string>
): 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 = {