diff --git a/Readme.md b/Readme.md index c6cca81..6f228f1 100644 --- a/Readme.md +++ b/Readme.md @@ -95,6 +95,28 @@ suggested that the general settings shall be at the end of your config file. The `IdentityFile` parameter always contain an array to make possible multiple `IdentityFile` settings to be able to coexist. +#### Case-Insensitive Matching + +OpenSSH treats configuration directives case-insensitively. By default, `compute()` +preserves the original case from the config file. To normalize directive names to +lowercase (matching OpenSSH behavior), use the `ignoreCase` option: + +```js +const config = SSHConfig.parse(` + Host example + hOsTnaME 1.2.3.4 + USER admin +`) + +// Default - preserves original case +config.compute('example') +// => { hOsTnaME: '1.2.3.4', USER: 'admin' } + +// With ignoreCase - lowercase to match OpenSSH +config.compute('example', { ignoreCase: true }) +// => { hostname: '1.2.3.4', user: 'admin' } +``` + ### `.find` sections by Host or Match **NOTICE**: This method is provided to find the corresponding section in the diff --git a/src/ssh-config.ts b/src/ssh-config.ts index ae37a43..05c66ed 100644 --- a/src/ssh-config.ts +++ b/src/ssh-config.ts @@ -135,6 +135,14 @@ export interface MatchOptions { User?: string; } +/** + * Options for computing SSH config results. + */ +export interface ComputeOptions { + /** when true, normalizes directive names to lowercase to match OpenSSH behavior */ + ignoreCase?: boolean; +} + interface MatchParams { Host: string; HostName: string; @@ -250,9 +258,14 @@ export default class SSHConfig extends Array { /** * Query SSH config by host and user. */ - public compute(opts: MatchOptions): Record; + public compute(opts: MatchOptions, computeOpts?: ComputeOptions): Record; + + /** + * Query SSH config by host with options. + */ + public compute(host: string, computeOpts?: ComputeOptions): Record; - public compute(opts: string | MatchOptions): Record { + public compute(opts: string | MatchOptions, computeOpts?: ComputeOptions): Record { if (typeof opts === 'string') opts = { Host: opts } let userInfo: { username: string } @@ -277,9 +290,10 @@ export default class SSHConfig extends Array { const obj: Record = {} const setProperty = (name: string, value: string | { val: string, separator: string, quoted?: boolean }[]) => { + const key = computeOpts?.ignoreCase ? name.toLowerCase() : name let val: string | string[] if (Array.isArray(value)) { - if (/ProxyCommand/i.test(name)) { + if (/ProxyCommand/i.test(key)) { val = value.map(({ val, separator, quoted }) => { return `${separator}${quoted ? `"${val.replace(/"/g, '\\"')}"` : val}` }).join('').trim() @@ -290,16 +304,18 @@ export default class SSHConfig extends Array { val = value } const val0 = Array.isArray(val) ? val[0] : val - if (REPEATABLE_DIRECTIVES.includes(name)) { - const list = (obj[name] || (obj[name] = [])) as string[] + // Use case-insensitive check for repeatable directives + const isRepeatable = REPEATABLE_DIRECTIVES.some(d => d.toLowerCase() === name.toLowerCase()) + if (isRepeatable) { + const list = (obj[key] || (obj[key] = [])) as string[] list.push(...([] as string[]).concat(val)) - } else if (obj[name] == null) { + } else if (obj[key] == null) { if (name === 'HostName') { context.params.HostName = val0 } else if (name === 'User') { context.params.User = val0 } - obj[name] = val + obj[key] = val } } @@ -310,7 +326,8 @@ export default class SSHConfig extends Array { const doPass = () => { for (const line of this) { if (line.type !== LineType.DIRECTIVE) continue - if (line.param === 'Host' && glob(Array.isArray(line.value) ? line.value.map(({ val }) => val) : line.value, context.params.Host)) { + // Host and Match directives are always case-insensitive (per OpenSSH behavior) + if (/^host$/i.test(line.param) && glob(Array.isArray(line.value) ? line.value.map(({ val }) => val) : line.value, context.params.Host)) { let canonicalizeHostName = false let canonicalDomains: string[] = [] setProperty(line.param, line.value) @@ -338,13 +355,13 @@ export default class SSHConfig extends Array { } } } - } else if (line.param === 'Match' && 'criteria' in line && match(line.criteria, context)) { + } else if (/^match$/i.test(line.param) && 'criteria' in line && match(line.criteria, context)) { for (const subline of (line as Section).config) { if (subline.type === LineType.DIRECTIVE) { setProperty(subline.param, subline.value) } } - } else if (line.param !== 'Host' && line.param !== 'Match') { + } else if (!/^(host|match)$/i.test(line.param)) { setProperty(line.param, line.value) } } diff --git a/test/unit/compute.test.ts b/test/unit/compute.test.ts index eccf8f4..b0e4e75 100644 --- a/test/unit/compute.test.ts +++ b/test/unit/compute.test.ts @@ -300,4 +300,109 @@ describe('compute', function() { assert.equal(result.ProxyCommand, '"/foo/bar - baz/proxylauncher.sh" "/some/param with space"') }) + describe('compute with ignoreCase', function() { + it('.compute with ignoreCase: true should normalize directive names to lowercase', async function() { + const config = SSHConfig.parse(` + Host example + hOsTnaME 1.2.3.4 + USER admin + PoRt 22 + `) + + const result = config.compute('example', { ignoreCase: true }) + + assert.equal(result.hostname, '1.2.3.4') + assert.equal(result.user, 'admin') + assert.equal(result.port, '22') + }) + + it('.compute with ignoreCase: true should normalize Host directive to lowercase', async function() { + const config = SSHConfig.parse(` + HOST example + HostName example.com + `) + + const result = config.compute('example', { ignoreCase: true }) + + assert.equal(result.host, 'example') + assert.equal(result.hostname, 'example.com') + }) + + it('.compute with ignoreCase: true should match HOST case-insensitively', async function() { + const config = SSHConfig.parse(` + HOST example + HostName example.com + `) + + const result = config.compute({ Host: 'example' }, { ignoreCase: true }) + + assert.ok(result) + assert.equal(result.hostname, 'example.com') + }) + + it('.compute with ignoreCase: true should match Match case-insensitively', async function() { + const config = SSHConfig.parse(` + MATCH host example + HostName example.com + `) + + const result = config.compute({ Host: 'example' }, { ignoreCase: true }) + + assert.ok(result) + assert.equal(result.hostname, 'example.com') + }) + + it('.compute with ignoreCase: false should preserve original case (default behavior)', async function() { + const config = SSHConfig.parse(` + Host example + hOsTnaME 1.2.3.4 + USER admin + `) + + const result = config.compute('example', { ignoreCase: false }) + + assert.equal(result.hOsTnaME, '1.2.3.4') + assert.equal(result.USER, 'admin') + }) + + it('.compute without ignoreCase should preserve original case', async function() { + const config = SSHConfig.parse(` + Host example + hOsTnaME 1.2.3.4 + USER admin + `) + + const result = config.compute('example') + + assert.equal(result.hOsTnaME, '1.2.3.4') + assert.equal(result.USER, 'admin') + }) + + it('.compute with ignoreCase should work with repeatable directives', async function() { + const config = SSHConfig.parse(` + Host example + IDENTITYFILE ~/.ssh/id_rsa + IDENTITYFILE ~/.ssh/id_ed25519 + `) + + const result = config.compute('example', { ignoreCase: true }) + + assert.deepEqual(result.identityfile, ['~/.ssh/id_rsa', '~/.ssh/id_ed25519']) + }) + + it('.compute with ignoreCase should normalize CanonicalizeHostName and CanonicalDomains', async function() { + const config = SSHConfig.parse(` + Host example + CANONICALIZEHOSTNAME yes + CANONICALDOMAINS example.com + `) + + const result = config.compute('example', { ignoreCase: true }) + + assert.equal(result.canonicalizehostname, 'yes') + // CanonicalDomains value is a single string, not an array + assert.equal(result.canonicaldomains, 'example.com') + }) + }) + })