fix(ssh): count only directory traversals in glob expansion budget

Plain files in a busy directory (e.g., many SSH keys in ~/.ssh) were
counting against the traversal budget, unnecessarily limiting glob
expansion. Refactor to use `withFileTypes: true` to distinguish
directories from files, and move `globWithTraversalLimit` to the
glob-readability module where directory budgets are already separated
from entry budgets.
This commit is contained in:
Jinjing
2026-09-28 20:16:19 -07:00
parent 03a32aff84
commit b458c5eb0a
4 changed files with 53 additions and 27 deletions
@@ -181,6 +181,19 @@ describe('SSH config Include completeness', () => {
expect(expandSshConfigIncludes(configPath).fullyExpanded).toBe(true)
})
it('does not charge plain files in a busy directory against the traversal budget', () => {
const home = makeTemporaryHome()
const configPath = writeFile(home, '.ssh/config', 'Include **/50-*\n')
writeFile(home, '.ssh/zz/50-prod', 'Host prod\n HostName prod.example.com\n')
for (let index = 0; index < 1100; index += 1) {
writeFile(home, `.ssh/id_key_${index}`, '')
}
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(expandSshConfigIncludes(configPath).content).toContain('prod.example.com')
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('walks more than'))
})
it('stops a glob that walks too many directories and marks it incomplete', () => {
const home = makeTemporaryHome()
const configPath = writeFile(home, '.ssh/config', 'Include conf.d/*/config\n')
+4 -23
View File
@@ -1,10 +1,12 @@
import { globSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { readFileSync, realpathSync, statSync } from 'node:fs'
import { homedir, hostname } from 'node:os'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import {
createGlobReadabilityProofs,
findGlobExpansionUncertainty,
globWithTraversalLimit,
hasGlobPattern,
MAX_INCLUDE_GLOB_TRAVERSAL,
type GlobExpansionUncertainty,
type GlobReadabilityProofs
} from './ssh-config-include-glob-readability'
@@ -43,8 +45,6 @@ type ResolvedIncludePaths = {
}
const MAX_INCLUDE_GLOB_MATCHES = 256
// Caps the directories a single Include glob may walk on the main process before we stop collecting.
const MAX_INCLUDE_GLOB_TRAVERSAL = 1024
const MAX_INCLUDE_FILE_BYTES = 1024 * 1024
export function expandSshConfigIncludes(configPath: string): SshConfigExpansion {
@@ -249,7 +249,7 @@ function resolveIncludePaths(
const absolutePattern = resolveIncludePatternPath(withTokens, context)
if (hasGlobPattern(absolutePattern)) {
try {
const { matches, traversalLimited } = globWithTraversalLimit(absolutePattern)
const { matches, traversalLimited } = globWithTraversalLimit(absolutePattern, context.pathApi)
matches.sort((left, right) => left.localeCompare(right))
if (traversalLimited) {
console.warn(
@@ -301,25 +301,6 @@ function resolveIncludePaths(
}
}
/** Stops descending once the walk exceeds its budget, keeping the matches already found. */
function globWithTraversalLimit(pattern: string): {
matches: string[]
traversalLimited: boolean
} {
let remaining = MAX_INCLUDE_GLOB_TRAVERSAL
let traversalLimited = false
const matches = globSync(pattern, {
exclude: () => {
remaining -= 1
if (remaining < 0) {
traversalLimited = true
}
return traversalLimited
}
})
return { matches, traversalLimited }
}
function getCanonicalPath(
filePath: string,
context: IncludeExpansionContext,
@@ -10,6 +10,8 @@ const MAX_GLOB_READABILITY_PATHS = 256
// larger allowance. A ~/.ssh holding hundreds of keys and control sockets is ordinary, and charging
// its entries against the path budget reported a perfectly readable directory as unopenable.
const MAX_GLOB_READABILITY_ENTRIES = 16_384
// Caps the directories a single Include glob may walk on the main process before we stop collecting.
export const MAX_INCLUDE_GLOB_TRAVERSAL = 1024
const GLOB_READABILITY_LIMIT_REACHED = Symbol('glob-readability-limit-reached')
/**
@@ -174,3 +176,25 @@ function getGlobDirectoryPrefixes(pattern: string, pathApi: PathApi): string[] {
}
return prefixes
}
/** Stops descending once the walk exceeds its budget, keeping the matches already found. */
export function globWithTraversalLimit(
pattern: string,
pathApi: PathApi
): { matches: string[]; traversalLimited: boolean } {
const walkedDirectories = new Set<string>()
let traversalLimited = false
const matches = globSync(pattern, {
withFileTypes: true,
exclude: (entry) => {
// Only descents cost a readdir; counting plain files would let a busy ~/.ssh trip the budget.
if (!traversalLimited && (entry.isDirectory() || entry.isSymbolicLink())) {
walkedDirectories.add(pathApi.join(entry.parentPath, entry.name))
traversalLimited = walkedDirectories.size > MAX_INCLUDE_GLOB_TRAVERSAL
}
return traversalLimited
}
})
const paths = matches.map((entry) => pathApi.join(entry.parentPath, entry.name))
return { matches: paths, traversalLimited }
}
@@ -1,6 +1,6 @@
import type * as FsModule from 'node:fs'
import type * as OsModule from 'node:os'
import { win32 } from 'node:path'
import { posix, win32 } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
afterEach(() => {
@@ -14,6 +14,14 @@ function normalizeWin(value: string): string {
return win32.normalize(value.replaceAll('/', '\\'))
}
/** Shapes paths like `globSync(..., { withFileTypes: true })` results. */
function globEntries(paths: string[]) {
return paths.map((entry) => {
const pathApi = /^[a-zA-Z]:\\/.test(entry) ? win32 : posix
return { parentPath: pathApi.dirname(entry), name: pathApi.basename(entry) }
})
}
function platformSshHome(): string {
return process.platform === 'win32' ? 'C:\\Users\\testuser' : '/home/testuser'
}
@@ -79,10 +87,10 @@ describe('loadUserSshConfig regressions', () => {
existsSync: (filePath: string) => files.has(normalizeWin(filePath)),
globSync: (pattern: string) =>
normalizeWin(pattern) === normalizeWin('C:/Users/Test User/.ssh/conf.d/*.conf')
? [
? globEntries([
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))
@@ -205,7 +213,7 @@ describe('loadUserSshConfig regressions', () => {
...actual,
existsSync: (filePath: string) =>
filePath === configPath || includePaths.includes(filePath),
globSync: () => [...includePaths].toReversed(),
globSync: () => globEntries([...includePaths].toReversed()),
readFileSync: (filePath: string) => {
if (filePath === configPath) {
return 'Include conf.d/*.conf\n'