Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 27 additions & 10 deletions src/ssh-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -250,9 +258,14 @@ export default class SSHConfig extends Array<Line> {
/**
* Query SSH config by host and user.
*/
public compute(opts: MatchOptions): Record<string, string | string[]>;
public compute(opts: MatchOptions, computeOpts?: ComputeOptions): Record<string, string | string[]>;

/**
* Query SSH config by host with options.
*/
public compute(host: string, computeOpts?: ComputeOptions): Record<string, string | string[]>;

public compute(opts: string | MatchOptions): Record<string, string | string[]> {
public compute(opts: string | MatchOptions, computeOpts?: ComputeOptions): Record<string, string | string[]> {
if (typeof opts === 'string') opts = { Host: opts }

let userInfo: { username: string }
Expand All @@ -277,9 +290,10 @@ export default class SSHConfig extends Array<Line> {

const obj: Record<string, string | string[]> = {}
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()
Expand All @@ -290,16 +304,18 @@ export default class SSHConfig extends Array<Line> {
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
}
}

Expand All @@ -310,7 +326,8 @@ export default class SSHConfig extends Array<Line> {
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)
Expand Down Expand Up @@ -338,13 +355,13 @@ export default class SSHConfig extends Array<Line> {
}
}
}
} 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)
}
}
Expand Down
105 changes: 105 additions & 0 deletions test/unit/compute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})

})