parent
59ac13dc53
commit
2dcaa78960
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue