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
5 changes: 5 additions & 0 deletions .changeset/case-check-access-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-import-x": patch
---

Fixed `no-unresolved` crashing when case-sensitive path checks encounter `EACCES` or `EPERM` on an ancestor directory.
35 changes: 27 additions & 8 deletions src/utils/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export const IMPORT_RESOLVE_ERROR_NAME = 'EslintPluginImportResolveError'

export const fileExistsCache = new ModuleCache()

function isDirectoryAccessError(error: unknown): boolean {
return (
typeof error === 'object' &&
error != null &&
'code' in error &&
(error.code === 'EACCES' || error.code === 'EPERM')
)
}

// https://stackoverflow.com/a/27382838
export function fileExistsWithCaseSync(
filepath: string | null,
Expand Down Expand Up @@ -76,14 +85,24 @@ export function fileExistsWithCaseSync(
if (dir === '' || parsedPath.root === filepath) {
result = true
} else {
const filenames = fs.readdirSync(dir)
result = filenames.includes(parsedPath.base)
? fileExistsWithCaseSync(dir, cacheSettings, strict, false)
: !leaf &&
// We tolerate case-insensitive matches if there are no case-insensitive matches.
// It'll fail anyway on the leaf node if the file truly doesn't exist (if it doesn't
// fail it's that we're probably working with a virtual in-memory filesystem).
!filenames.some(p => p.toLowerCase() === parsedPath.base.toLowerCase())
try {
const filenames = fs.readdirSync(dir)
result = filenames.includes(parsedPath.base)
? fileExistsWithCaseSync(dir, cacheSettings, strict, false)
: !leaf &&
// We tolerate case-insensitive matches if there are no case-insensitive matches.
// It'll fail anyway on the leaf node if the file truly doesn't exist (if it doesn't
// fail it's that we're probably working with a virtual in-memory filesystem).
!filenames.some(
p => p.toLowerCase() === parsedPath.base.toLowerCase(),
)
} catch (error) {
if (isDirectoryAccessError(error)) {
result = true
} else {
throw error
}
}
}
fileExistsCache.set(filepath, result)
return result
Expand Down
35 changes: 35 additions & 0 deletions test/utils/resolve.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { setTimeout } from 'node:timers/promises'

Expand Down Expand Up @@ -490,6 +491,40 @@ describe('resolve', () => {
)
expect(fileExistsWithCaseSync(f, cacheSettings, true)).toBe(false)
})

for (const code of ['EACCES', 'EPERM'] as const) {
it(`does not throw when ancestor directory access is denied outside cwd (${code})`, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'import-x-case-'))
const blockedDir = path.join(root, 'source')
const sharedDir = path.join(blockedDir, 'shared')
const file = path.join(sharedDir, 'account.db.ts')

fs.mkdirSync(sharedDir, { recursive: true })
fs.writeFileSync(file, 'export {}\n')

const originalReaddirSync = fs.readdirSync
const readdirSpy = jest.spyOn(fs, 'readdirSync').mockImplementation(((
dir: Parameters<typeof fs.readdirSync>[0],
) => {
if (path.resolve(String(dir)) === blockedDir) {
const error = new Error(`${code}: permission denied`) as Error & {
code: string
}
error.code = code
throw error
}

return originalReaddirSync(dir)
}) as typeof fs.readdirSync)

try {
expect(fileExistsWithCaseSync(file, cacheSettings)).toBe(true)
} finally {
readdirSpy.mockRestore()
fs.rmSync(root, { recursive: true, force: true })
}
})
}
})

describe('rename cache correctness', () => {
Expand Down
Loading