parent
1f307afa6d
commit
33c14bc716
|
|
@ -8,18 +8,26 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args'
|
|||
|
||||
// Why: ssh2 only tries keys that are explicitly provided. Users with keys in
|
||||
// standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would
|
||||
// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile.
|
||||
// fail to authenticate. Probe the regular and FIDO2 OpenSSH default paths.
|
||||
const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss']
|
||||
const DEFAULT_SECURITY_KEY_NAMES = ['id_ed25519_sk', 'id_ecdsa_sk']
|
||||
|
||||
const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`)
|
||||
const DEFAULT_IDENTITY_PATHS = [...DEFAULT_KEY_NAMES, ...DEFAULT_SECURITY_KEY_NAMES].map(
|
||||
(name) => `~/.ssh/${name}`
|
||||
)
|
||||
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
||||
|
||||
// Why: resolved IdentityFile paths are expanded before auth resolution, so they
|
||||
// won't match the ~/... form in DEFAULT_KEY_PATHS.
|
||||
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath)
|
||||
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_IDENTITY_PATHS.map(resolveSshConfigHomePath)
|
||||
|
||||
export type PrivateKeyFile = { path: string; contents: Buffer }
|
||||
|
||||
export function listDefaultIdentityFilePaths(): string[] {
|
||||
return [...DEFAULT_IDENTITY_PATHS]
|
||||
}
|
||||
|
||||
export function findDefaultKeyFile(): PrivateKeyFile | undefined {
|
||||
for (const keyPath of DEFAULT_KEY_PATHS) {
|
||||
const resolved = resolveSshConfigHomePath(keyPath)
|
||||
|
|
@ -97,7 +105,10 @@ function resolveExplicitPrivateKeyPaths(
|
|||
return resolvedIdentities
|
||||
}
|
||||
|
||||
function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
|
||||
export function resolveIdentityFilePaths(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
|
||||
): string[] {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return resolved.identityFile
|
||||
}
|
||||
|
|
@ -138,7 +149,7 @@ export function resolvePrivateKeys(
|
|||
target: SshTarget,
|
||||
resolved: SshResolvedConfig | null
|
||||
): PrivateKeyFile[] {
|
||||
const keyPaths = resolvePrivateKeyPaths(target, resolved)
|
||||
const keyPaths = resolveIdentityFilePaths(target, resolved)
|
||||
if (keyPaths.length > 0 || resolved || target.identityFile) {
|
||||
return readPrivateKeys(keyPaths)
|
||||
}
|
||||
|
|
@ -174,16 +185,6 @@ export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | un
|
|||
return undefined
|
||||
}
|
||||
|
||||
function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return resolved.identityFile
|
||||
}
|
||||
if (target.identityFile) {
|
||||
return [target.identityFile]
|
||||
}
|
||||
return resolved?.identityFile ?? []
|
||||
}
|
||||
|
||||
export function resolveAgentConfigValue(
|
||||
agentSocket: string,
|
||||
target: SshTarget,
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ describe('findDefaultKeyFile', () => {
|
|||
expect(result!.contents).toEqual(Buffer.from('key-contents'))
|
||||
})
|
||||
|
||||
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
|
||||
it('probes regular and FIDO2 keys in stable default order', () => {
|
||||
const checkedPaths: string[] = []
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
checkedPaths.push(String(path))
|
||||
|
|
@ -331,6 +331,34 @@ describe('findDefaultKeyFile', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('keeps a regular default ahead of a malformed FIDO2 default', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return (
|
||||
path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk')
|
||||
)
|
||||
})
|
||||
mockReadFileSync.mockImplementation((path: unknown) => {
|
||||
if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) {
|
||||
throw new Error('malformed FIDO2 key')
|
||||
}
|
||||
return Buffer.from('rsa-key')
|
||||
})
|
||||
|
||||
expect(findDefaultKeyFile()).toEqual({
|
||||
path: '~/.ssh/id_rsa',
|
||||
contents: Buffer.from('rsa-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return path === testHomePath('.ssh', 'id_ed25519_sk')
|
||||
})
|
||||
|
||||
expect(findDefaultKeyFile()).toBeUndefined()
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips unreadable key files and tries next', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa')
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ let execBehavior: 'callback' | 'pending' = 'callback'
|
|||
let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
|
||||
let sftpBehavior: 'callback' | 'pending' = 'callback'
|
||||
let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
|
||||
let notifyClientCreated: (() => void) | undefined
|
||||
|
||||
type MockSshClient = {
|
||||
setNoDelay: ReturnType<typeof vi.fn>
|
||||
|
|
@ -45,6 +46,8 @@ vi.mock('ssh2', () => {
|
|||
lastConnectConfig?: unknown
|
||||
constructor() {
|
||||
clientInstances.push(this)
|
||||
notifyClientCreated?.()
|
||||
notifyClientCreated = undefined
|
||||
}
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>()
|
||||
|
|
@ -168,6 +171,10 @@ import {
|
|||
} from './ssh-system-fallback'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import {
|
||||
createOpenSshPrivateKeyFixture,
|
||||
createOpenSshPublicKeyFixture
|
||||
} from './ssh-security-key-identity.test-fixture'
|
||||
|
||||
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
|
||||
return {
|
||||
|
|
@ -278,6 +285,7 @@ describe('SshConnection', () => {
|
|||
pendingExecCallback = null
|
||||
sftpBehavior = 'callback'
|
||||
pendingSftpCallback = null
|
||||
notifyClientCreated = undefined
|
||||
clientInstances = []
|
||||
getOrcaControlSocketPathMock.mockReset()
|
||||
getOrcaControlSocketPathMock.mockReturnValue(null)
|
||||
|
|
@ -462,10 +470,11 @@ describe('SshConnection', () => {
|
|||
const callbacks = createCallbacks()
|
||||
const conn = new SshConnection(createTarget(), callbacks)
|
||||
|
||||
const clientCreated = new Promise<void>((resolve) => {
|
||||
notifyClientCreated = resolve
|
||||
})
|
||||
const connectResult = conn.connect().catch((error: Error) => error)
|
||||
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await clientCreated
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
await conn.disconnect()
|
||||
|
||||
|
|
@ -485,10 +494,11 @@ describe('SshConnection', () => {
|
|||
const callbacks = createCallbacks()
|
||||
const conn = new SshConnection(createTarget(), callbacks)
|
||||
|
||||
const clientCreated = new Promise<void>((resolve) => {
|
||||
notifyClientCreated = resolve
|
||||
})
|
||||
const connectResult = conn.connect().catch((error: Error) => error)
|
||||
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await clientCreated
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
await conn.disconnect()
|
||||
|
||||
|
|
@ -1462,6 +1472,47 @@ describe('SshConnection', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('uses system SSH before ssh2 parses a security-key private key', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-'))
|
||||
const keyPath = join(directory, 'id_ed25519_sk')
|
||||
writeFileSync(
|
||||
keyPath,
|
||||
createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true })
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks())
|
||||
|
||||
try {
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(clientInstances).toHaveLength(0)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses system SSH for an agent-backed security-key public identity', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-'))
|
||||
const identityPath = join(directory, 'id_ed25519_sk')
|
||||
writeFileSync(
|
||||
`${identityPath}.pub`,
|
||||
createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com')
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks())
|
||||
|
||||
try {
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(clientInstances).toHaveLength(0)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)'
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ import {
|
|||
type SshConnectionCallbacks
|
||||
} from './ssh-connection-utils'
|
||||
import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication'
|
||||
import {
|
||||
requiresSystemSshForSecurityKey,
|
||||
shouldUseSystemSshTransport
|
||||
} from './ssh-transport-selection'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import {
|
||||
resolveSftpTransferPathIfMapped,
|
||||
|
|
@ -636,7 +640,14 @@ export class SshConnection {
|
|||
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
|
||||
() => null
|
||||
)
|
||||
if (shouldUseSystemSshTransport(this.target, resolved)) {
|
||||
const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved)
|
||||
const requiresSecurityKeyTransport = usesConfiguredSystemTransport
|
||||
? false
|
||||
: await requiresSystemSshForSecurityKey(this.target, resolved)
|
||||
if (!this.isCurrentConnectAttempt(connectGeneration)) {
|
||||
throw this.createCancelledConnectAttemptError()
|
||||
}
|
||||
if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) {
|
||||
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
|
||||
return
|
||||
}
|
||||
|
|
@ -1386,24 +1397,4 @@ export class SshConnection {
|
|||
}
|
||||
}
|
||||
|
||||
export function shouldUseSystemSshTransport(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'> | null
|
||||
): boolean {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
resolved.proxyUseFdpass === true ||
|
||||
resolved.proxyCommand != null ||
|
||||
resolved.proxyJump != null
|
||||
)
|
||||
}
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
target.proxyCommand != null ||
|
||||
target.jumpHost != null ||
|
||||
resolved?.proxyUseFdpass === true ||
|
||||
resolved?.proxyCommand != null ||
|
||||
resolved?.proxyJump != null
|
||||
)
|
||||
}
|
||||
export { shouldUseSystemSshTransport } from './ssh-transport-selection'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
function encodeUint32(value: number): Buffer {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32BE(value)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function sshString(value: string | Buffer): Buffer {
|
||||
const contents = typeof value === 'string' ? Buffer.from(value, 'ascii') : value
|
||||
return Buffer.concat([encodeUint32(contents.length), contents])
|
||||
}
|
||||
|
||||
export function createOpenSshPrivateKeyFixture(
|
||||
keyTypes: string[],
|
||||
options: { encrypted?: boolean; cipher?: string; privateBlock?: Buffer; authTag?: Buffer } = {}
|
||||
): Buffer {
|
||||
const cipher = options.cipher ?? (options.encrypted ? 'aes256-ctr' : 'none')
|
||||
const encrypted = cipher !== 'none'
|
||||
const publicKeys = keyTypes.map((keyType) => sshString(sshString(keyType)))
|
||||
const decoded = Buffer.concat([
|
||||
Buffer.from('openssh-key-v1\0', 'ascii'),
|
||||
sshString(cipher),
|
||||
sshString(encrypted ? 'bcrypt' : 'none'),
|
||||
sshString(encrypted ? Buffer.from('fixture-kdf') : Buffer.alloc(0)),
|
||||
encodeUint32(publicKeys.length),
|
||||
...publicKeys,
|
||||
sshString(options.privateBlock ?? Buffer.from('fixture-private-block')),
|
||||
options.authTag ?? Buffer.alloc(0)
|
||||
])
|
||||
const encoded =
|
||||
decoded
|
||||
.toString('base64')
|
||||
.match(/.{1,70}/g)
|
||||
?.join('\n') ?? ''
|
||||
return Buffer.from(
|
||||
`-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n`
|
||||
)
|
||||
}
|
||||
|
||||
export function createOpenSshPublicKeyFixture(keyType: string): Buffer {
|
||||
const encoded = sshString(keyType).toString('base64')
|
||||
return Buffer.from(`${keyType} ${encoded} fixture-comment\n`)
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import {
|
||||
isOpenSshSecurityKeyPrivateKey,
|
||||
isOpenSshSecurityKeyPublicKey
|
||||
} from './ssh-security-key-identity'
|
||||
import {
|
||||
createOpenSshPrivateKeyFixture,
|
||||
createOpenSshPublicKeyFixture
|
||||
} from './ssh-security-key-identity.test-fixture'
|
||||
import { requiresSystemSshForSecurityKey } from './ssh-transport-selection'
|
||||
|
||||
const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() }))
|
||||
|
||||
vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock }))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<{ homedir: () => string }>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => process.env.ORCA_TEST_SSH_HOME || actual.homedir()
|
||||
}
|
||||
})
|
||||
|
||||
const ED25519_SECURITY_KEY = 'sk-ssh-ed25519@openssh.com'
|
||||
const ECDSA_SECURITY_KEY = 'sk-ecdsa-sha2-nistp256@openssh.com'
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function createTarget(overrides: Partial<SshTarget> = {}): SshTarget {
|
||||
return {
|
||||
id: 'target-1',
|
||||
label: 'Test Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findSystemSshMock.mockReset()
|
||||
findSystemSshMock.mockReturnValue('/usr/bin/ssh')
|
||||
})
|
||||
|
||||
async function writeKey(contents: Buffer, filename = 'security key'): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-'))
|
||||
tempDirs.push(directory)
|
||||
const keyPath = join(directory, filename)
|
||||
await writeFile(keyPath, contents)
|
||||
return keyPath
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('isOpenSshSecurityKeyPrivateKey', () => {
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'recognizes unencrypted %s keys',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('recognizes authenticated encrypted envelopes with a trailing tag', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
|
||||
cipher: 'aes256-gcm@openssh.com',
|
||||
authTag: Buffer.alloc(16, 7)
|
||||
})
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'recognizes encrypted %s keys from the public section',
|
||||
(keyType) => {
|
||||
const key = createOpenSshPrivateKeyFixture([keyType], { encrypted: true })
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ssh-rsa'])(
|
||||
'leaves regular %s keys on ssh2',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('supports CRLF armored keys', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
expect(
|
||||
isOpenSshSecurityKeyPrivateKey(Buffer.from(key.toString().replaceAll('\n', '\r\n')))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes OpenSSH envelopes without optional base64 padding', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
|
||||
privateBlock: Buffer.alloc(0)
|
||||
})
|
||||
const unpadded = Buffer.from(key.toString().replace(/=+(?=\n-----END)/, ''))
|
||||
expect(unpadded).not.toEqual(key)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(unpadded)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match security-key text outside a valid public-key type', () => {
|
||||
const key = createOpenSshPrivateKeyFixture(['ssh-ed25519'], {
|
||||
privateBlock: Buffer.from(ED25519_SECURITY_KEY)
|
||||
})
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(false)
|
||||
expect(
|
||||
isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'validates the %s type inside an OpenSSH public key blob',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture(keyType))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('recognizes public keys without optional base64 padding', () => {
|
||||
const key = createOpenSshPublicKeyFixture(ECDSA_SECURITY_KEY)
|
||||
const unpadded = Buffer.from(key.toString().replace(/=+(?=\s)/, ''))
|
||||
expect(unpadded).not.toEqual(key)
|
||||
expect(isOpenSshSecurityKeyPublicKey(unpadded)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects regular or mismatched OpenSSH public key blobs', () => {
|
||||
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false)
|
||||
expect(
|
||||
isOpenSshSecurityKeyPublicKey(
|
||||
Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed and truncated OpenSSH envelopes without throwing', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
const malformedLength = Buffer.concat([
|
||||
Buffer.from('openssh-key-v1\0', 'ascii'),
|
||||
Buffer.from([0xff, 0xff, 0xff, 0xff])
|
||||
]).toString('base64')
|
||||
const malformedKey = Buffer.from(
|
||||
`-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n`
|
||||
)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiresSystemSshForSecurityKey', () => {
|
||||
it('uses default FIDO2 identities only when config resolution is unavailable', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget(), { identityFile: [] })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a regular default ahead of a dormant FIDO2 identity', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa']))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
findSystemSshMock.mockReturnValue(null)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519.pub'),
|
||||
createOpenSshPublicKeyFixture('ssh-ed25519')
|
||||
)
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('detects a manual target identity path with spaces', async () => {
|
||||
const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]))
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('checks every fresh resolved identity for config-backed targets', async () => {
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
|
||||
const securityKey = await writeKey(
|
||||
createOpenSshPrivateKeyFixture([ECDSA_SECURITY_KEY], { encrypted: true }),
|
||||
'security'
|
||||
)
|
||||
const target = createTarget({
|
||||
source: 'ssh-config',
|
||||
configHost: 'workbox',
|
||||
identityFile: '/stale/security-key'
|
||||
})
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey, securityKey] })
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'detects an agent-backed %s identity from its public sidecar',
|
||||
async (keyType) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-agent-'))
|
||||
tempDirs.push(directory)
|
||||
const identityPath = join(directory, 'agent-key')
|
||||
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(keyType))
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
|
||||
).resolves.toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores a stale FIDO2 sidecar beside a regular private identity', async () => {
|
||||
const identityPath = await writeKey(
|
||||
createOpenSshPrivateKeyFixture(['ssh-ed25519']),
|
||||
'regular-with-stale-sidecar'
|
||||
)
|
||||
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(ED25519_SECURITY_KEY))
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale imported identity paths when fresh config has regular keys', async () => {
|
||||
const staleKey = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), 'stale')
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
|
||||
const target = createTarget({
|
||||
source: 'ssh-config',
|
||||
configHost: 'workbox',
|
||||
identityFile: staleKey
|
||||
})
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey] })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a manual target identity authoritative over resolved defaults', async () => {
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'manual')
|
||||
const securityKey = await writeKey(
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]),
|
||||
'resolved'
|
||||
)
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(
|
||||
createTarget({ source: 'manual', identityFile: regularKey }),
|
||||
{ identityFile: [securityKey] }
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('degrades to ssh2 when identity files are missing or malformed', async () => {
|
||||
const malformedKey = await writeKey(Buffer.from('not a key'), 'malformed')
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: malformedKey }), null)
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(
|
||||
createTarget({ identityFile: join(tmpdir(), 'missing-security-key') }),
|
||||
null
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
const OPENSSH_PRIVATE_KEY_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----'
|
||||
const OPENSSH_PRIVATE_KEY_FOOTER = '-----END OPENSSH PRIVATE KEY-----'
|
||||
const OPENSSH_KEY_MAGIC = Buffer.from('openssh-key-v1\0', 'ascii')
|
||||
export const MAX_SSH_IDENTITY_FILE_BYTES = 1024 * 1024
|
||||
const MAX_PUBLIC_KEYS = 64
|
||||
const SECURITY_KEY_TYPES = new Set([
|
||||
'sk-ssh-ed25519@openssh.com',
|
||||
'sk-ecdsa-sha2-nistp256@openssh.com'
|
||||
])
|
||||
|
||||
type SshString = { value: Buffer; nextOffset: number }
|
||||
|
||||
function decodeBase64(value: string): Buffer | null {
|
||||
if (!value || value.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
const unpadded = value.replace(/=+$/, '')
|
||||
const decoded = Buffer.from(unpadded, 'base64')
|
||||
return decoded.toString('base64').replace(/=+$/, '') === unpadded ? decoded : null
|
||||
}
|
||||
|
||||
function readSshString(buffer: Buffer, offset: number): SshString | null {
|
||||
if (offset < 0 || offset + 4 > buffer.length) {
|
||||
return null
|
||||
}
|
||||
const length = buffer.readUInt32BE(offset)
|
||||
const start = offset + 4
|
||||
const end = start + length
|
||||
if (end < start || end > buffer.length) {
|
||||
return null
|
||||
}
|
||||
return { value: buffer.subarray(start, end), nextOffset: end }
|
||||
}
|
||||
|
||||
function decodeOpenSshPrivateKey(contents: Buffer): Buffer | null {
|
||||
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const lines = contents.toString('ascii').trim().split(/\r?\n/)
|
||||
if (lines[0] !== OPENSSH_PRIVATE_KEY_HEADER || lines.at(-1) !== OPENSSH_PRIVATE_KEY_FOOTER) {
|
||||
return null
|
||||
}
|
||||
const encoded = lines.slice(1, -1).join('')
|
||||
const decoded = decodeBase64(encoded)
|
||||
if (!decoded) {
|
||||
return null
|
||||
}
|
||||
return decoded.subarray(0, OPENSSH_KEY_MAGIC.length).equals(OPENSSH_KEY_MAGIC) ? decoded : null
|
||||
}
|
||||
|
||||
export function isOpenSshSecurityKeyPrivateKey(contents: Buffer): boolean {
|
||||
const decoded = decodeOpenSshPrivateKey(contents)
|
||||
if (!decoded) {
|
||||
return false
|
||||
}
|
||||
|
||||
let offset = OPENSSH_KEY_MAGIC.length
|
||||
for (let field = 0; field < 3; field++) {
|
||||
const value = readSshString(decoded, offset)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
offset = value.nextOffset
|
||||
}
|
||||
if (offset + 4 > decoded.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keyCount = decoded.readUInt32BE(offset)
|
||||
offset += 4
|
||||
if (keyCount === 0 || keyCount > MAX_PUBLIC_KEYS) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasSecurityKey = false
|
||||
for (let keyIndex = 0; keyIndex < keyCount; keyIndex++) {
|
||||
const publicKey = readSshString(decoded, offset)
|
||||
if (!publicKey) {
|
||||
return false
|
||||
}
|
||||
offset = publicKey.nextOffset
|
||||
const keyType = readSshString(publicKey.value, 0)
|
||||
if (!keyType) {
|
||||
return false
|
||||
}
|
||||
hasSecurityKey ||= SECURITY_KEY_TYPES.has(keyType.value.toString('ascii'))
|
||||
}
|
||||
|
||||
const privateBlock = readSshString(decoded, offset)
|
||||
return privateBlock !== null && hasSecurityKey
|
||||
}
|
||||
|
||||
export function isOpenSshSecurityKeyPublicKey(contents: Buffer): boolean {
|
||||
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return false
|
||||
}
|
||||
const [declaredType, encoded] = contents.toString('ascii').trim().split(/\s+/, 3)
|
||||
if (!declaredType || !encoded || !SECURITY_KEY_TYPES.has(declaredType)) {
|
||||
return false
|
||||
}
|
||||
const decoded = decodeBase64(encoded)
|
||||
if (!decoded) {
|
||||
return false
|
||||
}
|
||||
const keyType = readSshString(decoded, 0)
|
||||
return keyType?.value.toString('ascii') === declaredType
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ vi.mock('child_process', () => ({
|
|||
|
||||
import {
|
||||
buildSshArgs,
|
||||
findSystemSsh,
|
||||
downloadFileViaSystemSsh,
|
||||
spawnSystemSsh,
|
||||
spawnSystemSshCommand,
|
||||
|
|
@ -152,22 +151,6 @@ function createMockChildProcess(): EventEmitter & {
|
|||
return child
|
||||
}
|
||||
|
||||
describe('findSystemSsh', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns the first existing ssh path', () => {
|
||||
mockSystemSshExists()
|
||||
expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH)
|
||||
})
|
||||
|
||||
it('returns null when no ssh binary is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(findSystemSsh()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnSystemSsh', () => {
|
||||
let mockProc: {
|
||||
stdin: {
|
||||
|
|
@ -827,6 +810,7 @@ describe('spawnSystemSsh', () => {
|
|||
|
||||
it('throws when no system ssh is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
vi.stubEnv('PATH', '')
|
||||
expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { constants } from 'node:fs'
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { SshResolvedConfig } from './ssh-config-parser'
|
||||
import { listDefaultIdentityFilePaths, resolveIdentityFilePaths } from './ssh-auth-resolution'
|
||||
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
|
||||
import {
|
||||
MAX_SSH_IDENTITY_FILE_BYTES,
|
||||
isOpenSshSecurityKeyPrivateKey,
|
||||
isOpenSshSecurityKeyPublicKey
|
||||
} from './ssh-security-key-identity'
|
||||
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
|
||||
import { findSystemSsh } from './system-ssh-binary'
|
||||
|
||||
type TransportResolvedConfig = Pick<
|
||||
SshResolvedConfig,
|
||||
'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'
|
||||
>
|
||||
|
||||
const READ_CHUNK_BYTES = 64 * 1024
|
||||
const READ_OPEN_FLAGS =
|
||||
constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
|
||||
|
||||
type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean }
|
||||
|
||||
async function readBoundedKeyFile(path: string): Promise<Buffer | null> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
try {
|
||||
const pathStats = await stat(path)
|
||||
if (!pathStats.isFile() || pathStats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
handle = await open(path, READ_OPEN_FLAGS)
|
||||
const stats = await handle.stat()
|
||||
if (!stats.isFile() || stats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let offset = 0
|
||||
while (offset <= MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
const buffer = Buffer.alloc(
|
||||
Math.min(READ_CHUNK_BYTES, MAX_SSH_IDENTITY_FILE_BYTES + 1 - offset)
|
||||
)
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
offset += bytesRead
|
||||
if (offset > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
chunks.push(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
return Buffer.concat(chunks, offset)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectIdentityPath(keyPath: string): Promise<IdentityInspection> {
|
||||
const resolvedPath = resolveSshConfigHomePath(keyPath)
|
||||
const identity = await readBoundedKeyFile(resolvedPath)
|
||||
if (identity !== null) {
|
||||
return {
|
||||
privateIdentityExists: true,
|
||||
requiresSystemSsh:
|
||||
isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity)
|
||||
}
|
||||
}
|
||||
const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`)
|
||||
return {
|
||||
privateIdentityExists: false,
|
||||
requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseSystemSshTransport(
|
||||
target: SshTarget,
|
||||
resolved: TransportResolvedConfig | null
|
||||
): boolean {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
resolved.proxyUseFdpass === true ||
|
||||
resolved.proxyCommand != null ||
|
||||
resolved.proxyJump != null
|
||||
)
|
||||
}
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
target.proxyCommand != null ||
|
||||
target.jumpHost != null ||
|
||||
resolved?.proxyUseFdpass === true ||
|
||||
resolved?.proxyCommand != null ||
|
||||
resolved?.proxyJump != null
|
||||
)
|
||||
}
|
||||
|
||||
export async function requiresSystemSshForSecurityKey(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
|
||||
): Promise<boolean> {
|
||||
const configuredPaths = resolveIdentityFilePaths(target, resolved)
|
||||
const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile
|
||||
const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths
|
||||
for (const keyPath of identityPaths) {
|
||||
const inspection = await inspectIdentityPath(keyPath)
|
||||
if (inspection.requiresSystemSsh) {
|
||||
return !usesDefaultPaths || findSystemSsh() !== null
|
||||
}
|
||||
if (usesDefaultPaths && inspection.privateIdentityExists) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { accessSyncMock, existsSyncMock, statSyncMock } = vi.hoisted(() => ({
|
||||
accessSyncMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
accessSync: accessSyncMock,
|
||||
constants: { O_RDONLY: 0, O_NONBLOCK: 4, X_OK: 1 },
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock
|
||||
}))
|
||||
|
||||
import { findSystemSsh } from './system-ssh-binary'
|
||||
|
||||
describe('findSystemSsh', () => {
|
||||
beforeEach(() => {
|
||||
accessSyncMock.mockReset()
|
||||
existsSyncMock.mockReset()
|
||||
statSyncMock.mockReset()
|
||||
statSyncMock.mockImplementation(() => {
|
||||
throw new Error('missing')
|
||||
})
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns the first existing fixed ssh path', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/ssh')
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('/usr/bin/ssh')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when no ssh binary is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(findSystemSsh()).toBeNull()
|
||||
})
|
||||
|
||||
it('finds a PATH-installed ssh.exe on Windows', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('PATH', 'C:\\Git\\usr\\bin;C:\\Tools')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
statSyncMock.mockImplementation((path: string) => {
|
||||
if (path === 'C:\\Git\\usr\\bin\\ssh.exe') {
|
||||
return { isFile: () => true }
|
||||
}
|
||||
throw new Error('missing')
|
||||
})
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('C:\\Git\\usr\\bin\\ssh.exe')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('finds an executable PATH-installed ssh on POSIX', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
vi.stubEnv('PATH', '/nix/store/ssh/bin:/opt/tools')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
statSyncMock.mockImplementation((path: string) => {
|
||||
if (path === '/nix/store/ssh/bin/ssh') {
|
||||
return { isFile: () => true }
|
||||
}
|
||||
throw new Error('missing')
|
||||
})
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('/nix/store/ssh/bin/ssh')
|
||||
expect(accessSyncMock).toHaveBeenCalledWith('/nix/store/ssh/bin/ssh', expect.any(Number))
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves Windows OpenSSH from the runtime system root', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('SystemRoot', 'D:\\Windows')
|
||||
existsSyncMock.mockImplementation(
|
||||
(path: string) => path === 'D:\\Windows\\System32\\OpenSSH\\ssh.exe'
|
||||
)
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('D:\\Windows\\System32\\OpenSSH\\ssh.exe')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an explicit system ssh override authoritative', () => {
|
||||
vi.stubEnv('ORCA_SYSTEM_SSH_PATH', 'C:\\Custom\\ssh.exe')
|
||||
|
||||
expect(findSystemSsh()).toBe('C:\\Custom\\ssh.exe')
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,9 +1,41 @@
|
|||
import { existsSync } from 'node:fs'
|
||||
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
const SYSTEM_SSH_PATHS =
|
||||
process.platform === 'win32'
|
||||
? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe']
|
||||
: ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
|
||||
function systemSshPaths(platform: NodeJS.Platform): string[] {
|
||||
if (platform !== 'win32') {
|
||||
return ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
|
||||
}
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR
|
||||
return systemRoot ? [win32.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe')] : []
|
||||
}
|
||||
|
||||
function findSshOnPath(platform: NodeJS.Platform): string | null {
|
||||
const pathValue = process.env.PATH
|
||||
if (!pathValue) {
|
||||
return null
|
||||
}
|
||||
const pathApi = platform === 'win32' ? win32 : posix
|
||||
const executable = platform === 'win32' ? 'ssh.exe' : 'ssh'
|
||||
for (const entry of pathValue.split(pathApi.delimiter)) {
|
||||
const directory = entry.trim().replace(/^"|"$/g, '')
|
||||
if (!directory) {
|
||||
continue
|
||||
}
|
||||
const candidate = pathApi.join(directory, executable)
|
||||
try {
|
||||
if (!statSync(candidate).isFile()) {
|
||||
continue
|
||||
}
|
||||
if (platform !== 'win32') {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
}
|
||||
return candidate
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the system ssh binary path. Returns null if not found.
|
||||
|
|
@ -12,10 +44,10 @@ export function findSystemSsh(): string | null {
|
|||
if (process.env.ORCA_SYSTEM_SSH_PATH) {
|
||||
return process.env.ORCA_SYSTEM_SSH_PATH
|
||||
}
|
||||
for (const candidate of SYSTEM_SSH_PATHS) {
|
||||
for (const candidate of systemSshPaths(process.platform)) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
return findSshOnPath(process.platform)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue