Skip to content

Commit 16d5218

Browse files
authored
feat: add ignoreCase option to compute() for case-insensitive directive matching (#105)
* feat: add ignoreCase option to compute() for case-insensitive directive matching - Add ComputeOptions interface with ignoreCase property - Update compute() to accept optional second argument for compute options - Normalize directive names to lowercase when ignoreCase is true - Add comprehensive test cases for ignoreCase functionality Closes #88 * docs: add ignoreCase feature documentation to README
1 parent ad91e46 commit 16d5218

3 files changed

Lines changed: 154 additions & 10 deletions

File tree

Readme.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,28 @@ suggested that the general settings shall be at the end of your config file.
9595
The `IdentityFile` parameter always contain an array to make possible multiple
9696
`IdentityFile` settings to be able to coexist.
9797

98+
#### Case-Insensitive Matching
99+
100+
OpenSSH treats configuration directives case-insensitively. By default, `compute()`
101+
preserves the original case from the config file. To normalize directive names to
102+
lowercase (matching OpenSSH behavior), use the `ignoreCase` option:
103+
104+
```js
105+
const config = SSHConfig.parse(`
106+
Host example
107+
hOsTnaME 1.2.3.4
108+
USER admin
109+
`)
110+
111+
// Default - preserves original case
112+
config.compute('example')
113+
// => { hOsTnaME: '1.2.3.4', USER: 'admin' }
114+
115+
// With ignoreCase - lowercase to match OpenSSH
116+
config.compute('example', { ignoreCase: true })
117+
// => { hostname: '1.2.3.4', user: 'admin' }
118+
```
119+
98120
### `.find` sections by Host or Match
99121

100122
**NOTICE**: This method is provided to find the corresponding section in the

src/ssh-config.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ export interface MatchOptions {
135135
User?: string;
136136
}
137137

138+
/**
139+
* Options for computing SSH config results.
140+
*/
141+
export interface ComputeOptions {
142+
/** when true, normalizes directive names to lowercase to match OpenSSH behavior */
143+
ignoreCase?: boolean;
144+
}
145+
138146
interface MatchParams {
139147
Host: string;
140148
HostName: string;
@@ -250,9 +258,14 @@ export default class SSHConfig extends Array<Line> {
250258
/**
251259
* Query SSH config by host and user.
252260
*/
253-
public compute(opts: MatchOptions): Record<string, string | string[]>;
261+
public compute(opts: MatchOptions, computeOpts?: ComputeOptions): Record<string, string | string[]>;
262+
263+
/**
264+
* Query SSH config by host with options.
265+
*/
266+
public compute(host: string, computeOpts?: ComputeOptions): Record<string, string | string[]>;
254267

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

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

278291
const obj: Record<string, string | string[]> = {}
279292
const setProperty = (name: string, value: string | { val: string, separator: string, quoted?: boolean }[]) => {
293+
const key = computeOpts?.ignoreCase ? name.toLowerCase() : name
280294
let val: string | string[]
281295
if (Array.isArray(value)) {
282-
if (/ProxyCommand/i.test(name)) {
296+
if (/ProxyCommand/i.test(key)) {
283297
val = value.map(({ val, separator, quoted }) => {
284298
return `${separator}${quoted ? `"${val.replace(/"/g, '\\"')}"` : val}`
285299
}).join('').trim()
@@ -290,16 +304,18 @@ export default class SSHConfig extends Array<Line> {
290304
val = value
291305
}
292306
const val0 = Array.isArray(val) ? val[0] : val
293-
if (REPEATABLE_DIRECTIVES.includes(name)) {
294-
const list = (obj[name] || (obj[name] = [])) as string[]
307+
// Use case-insensitive check for repeatable directives
308+
const isRepeatable = REPEATABLE_DIRECTIVES.some(d => d.toLowerCase() === name.toLowerCase())
309+
if (isRepeatable) {
310+
const list = (obj[key] || (obj[key] = [])) as string[]
295311
list.push(...([] as string[]).concat(val))
296-
} else if (obj[name] == null) {
312+
} else if (obj[key] == null) {
297313
if (name === 'HostName') {
298314
context.params.HostName = val0
299315
} else if (name === 'User') {
300316
context.params.User = val0
301317
}
302-
obj[name] = val
318+
obj[key] = val
303319
}
304320
}
305321

@@ -310,7 +326,8 @@ export default class SSHConfig extends Array<Line> {
310326
const doPass = () => {
311327
for (const line of this) {
312328
if (line.type !== LineType.DIRECTIVE) continue
313-
if (line.param === 'Host' && glob(Array.isArray(line.value) ? line.value.map(({ val }) => val) : line.value, context.params.Host)) {
329+
// Host and Match directives are always case-insensitive (per OpenSSH behavior)
330+
if (/^host$/i.test(line.param) && glob(Array.isArray(line.value) ? line.value.map(({ val }) => val) : line.value, context.params.Host)) {
314331
let canonicalizeHostName = false
315332
let canonicalDomains: string[] = []
316333
setProperty(line.param, line.value)
@@ -338,13 +355,13 @@ export default class SSHConfig extends Array<Line> {
338355
}
339356
}
340357
}
341-
} else if (line.param === 'Match' && 'criteria' in line && match(line.criteria, context)) {
358+
} else if (/^match$/i.test(line.param) && 'criteria' in line && match(line.criteria, context)) {
342359
for (const subline of (line as Section).config) {
343360
if (subline.type === LineType.DIRECTIVE) {
344361
setProperty(subline.param, subline.value)
345362
}
346363
}
347-
} else if (line.param !== 'Host' && line.param !== 'Match') {
364+
} else if (!/^(host|match)$/i.test(line.param)) {
348365
setProperty(line.param, line.value)
349366
}
350367
}

test/unit/compute.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,4 +300,109 @@ describe('compute', function() {
300300
assert.equal(result.ProxyCommand, '"/foo/bar - baz/proxylauncher.sh" "/some/param with space"')
301301
})
302302

303+
describe('compute with ignoreCase', function() {
304+
it('.compute with ignoreCase: true should normalize directive names to lowercase', async function() {
305+
const config = SSHConfig.parse(`
306+
Host example
307+
hOsTnaME 1.2.3.4
308+
USER admin
309+
PoRt 22
310+
`)
311+
312+
const result = config.compute('example', { ignoreCase: true })
313+
314+
assert.equal(result.hostname, '1.2.3.4')
315+
assert.equal(result.user, 'admin')
316+
assert.equal(result.port, '22')
317+
})
318+
319+
it('.compute with ignoreCase: true should normalize Host directive to lowercase', async function() {
320+
const config = SSHConfig.parse(`
321+
HOST example
322+
HostName example.com
323+
`)
324+
325+
const result = config.compute('example', { ignoreCase: true })
326+
327+
assert.equal(result.host, 'example')
328+
assert.equal(result.hostname, 'example.com')
329+
})
330+
331+
it('.compute with ignoreCase: true should match HOST case-insensitively', async function() {
332+
const config = SSHConfig.parse(`
333+
HOST example
334+
HostName example.com
335+
`)
336+
337+
const result = config.compute({ Host: 'example' }, { ignoreCase: true })
338+
339+
assert.ok(result)
340+
assert.equal(result.hostname, 'example.com')
341+
})
342+
343+
it('.compute with ignoreCase: true should match Match case-insensitively', async function() {
344+
const config = SSHConfig.parse(`
345+
MATCH host example
346+
HostName example.com
347+
`)
348+
349+
const result = config.compute({ Host: 'example' }, { ignoreCase: true })
350+
351+
assert.ok(result)
352+
assert.equal(result.hostname, 'example.com')
353+
})
354+
355+
it('.compute with ignoreCase: false should preserve original case (default behavior)', async function() {
356+
const config = SSHConfig.parse(`
357+
Host example
358+
hOsTnaME 1.2.3.4
359+
USER admin
360+
`)
361+
362+
const result = config.compute('example', { ignoreCase: false })
363+
364+
assert.equal(result.hOsTnaME, '1.2.3.4')
365+
assert.equal(result.USER, 'admin')
366+
})
367+
368+
it('.compute without ignoreCase should preserve original case', async function() {
369+
const config = SSHConfig.parse(`
370+
Host example
371+
hOsTnaME 1.2.3.4
372+
USER admin
373+
`)
374+
375+
const result = config.compute('example')
376+
377+
assert.equal(result.hOsTnaME, '1.2.3.4')
378+
assert.equal(result.USER, 'admin')
379+
})
380+
381+
it('.compute with ignoreCase should work with repeatable directives', async function() {
382+
const config = SSHConfig.parse(`
383+
Host example
384+
IDENTITYFILE ~/.ssh/id_rsa
385+
IDENTITYFILE ~/.ssh/id_ed25519
386+
`)
387+
388+
const result = config.compute('example', { ignoreCase: true })
389+
390+
assert.deepEqual(result.identityfile, ['~/.ssh/id_rsa', '~/.ssh/id_ed25519'])
391+
})
392+
393+
it('.compute with ignoreCase should normalize CanonicalizeHostName and CanonicalDomains', async function() {
394+
const config = SSHConfig.parse(`
395+
Host example
396+
CANONICALIZEHOSTNAME yes
397+
CANONICALDOMAINS example.com
398+
`)
399+
400+
const result = config.compute('example', { ignoreCase: true })
401+
402+
assert.equal(result.canonicalizehostname, 'yes')
403+
// CanonicalDomains value is a single string, not an array
404+
assert.equal(result.canonicaldomains, 'example.com')
405+
})
406+
})
407+
303408
})

0 commit comments

Comments
 (0)