diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f65049a..6c86f24e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **Bazel diagnostics** — `socket manifest bazel --verbose` now emits bounded subprocess traces with argv, cwd, duration, exit status, output sizes, and failure stderr tails to make customer log-only triage safer and faster. +## [1.1.112](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.112) - 2026-05-29 + +### Fixed +- `socket fix` and `socket scan create` no longer abort with `EACCES: permission denied, scandir` when the project contains a directory the running user cannot read (for example a postgres `pgdata` data directory owned by another uid, or a Docker volume mount). Manifest discovery walks a project for `.gitignore` files before applying any path exclusions; that walk now honors `--exclude-paths` and `socket.yml` `projectIgnorePaths`, and skips unreadable directories rather than crashing. This makes `--exclude-paths` effective for unreadable directories — previously the crash happened before the exclusion was ever applied. + ## [1.1.111](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.111) - 2026-05-29 ### Changed diff --git a/package.json b/package.json index 8fccb371f..862d49138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "socket", - "version": "1.1.111", + "version": "1.1.112", "description": "CLI for Socket.dev", "homepage": "https://github.com/SocketDev/socket-cli", "license": "MIT AND OFL-1.1", diff --git a/src/utils/glob.mts b/src/utils/glob.mts index dd89f37ef..03824c16e 100644 --- a/src/utils/glob.mts +++ b/src/utils/glob.mts @@ -232,23 +232,48 @@ export async function globWithGitIgnore( const ignores = new Set(IGNORED_DIR_PATTERNS) + // CLI-supplied `additionalIgnores` are already anchored minimatch — they + // must not pass through the `ignore` package (whose gitignore "match + // anywhere" semantics would re-interpret a bare `tests` to match + // `subdir/tests/foo.json`). Keep them in fast-glob's ignore list across + // both paths; only gitignore-translated entries go into the `ig` matcher. + const cliMinimatchIgnores = additionalIgnores ?? [] + const projectIgnorePaths = socketConfig?.projectIgnorePaths - if (Array.isArray(projectIgnorePaths)) { - const ignorePatterns = ignoreFileLinesToGlobPatterns( - projectIgnorePaths, - path.join(cwd, '.gitignore'), - cwd, - ) - for (const pattern of ignorePatterns) { - ignores.add(pattern) - } + const projectIgnoreGlobs = Array.isArray(projectIgnorePaths) + ? ignoreFileLinesToGlobPatterns( + projectIgnorePaths, + path.join(cwd, '.gitignore'), + cwd, + ) + : [] + for (const pattern of projectIgnoreGlobs) { + ignores.add(pattern) } + // The .gitignore discovery walk has to honor the same directory exclusions + // as the package walk below. Otherwise an unreadable subtree (e.g. a + // postgres `pgdata` dir owned by another uid, or a Docker volume mount) makes + // fast-glob throw `EACCES: permission denied, scandir` *here* — before + // --exclude-paths (`cliMinimatchIgnores`) or projectIgnorePaths are ever + // applied to the main walk, which is why excluding the path did not help. + // `suppressErrors` is the backstop: a directory the user simply cannot read + // cannot contain manifests they could scan anyway, so skip it instead of + // aborting the whole `socket fix` / `socket scan` run. Negated patterns are + // dropped — for a discovery walk they could only re-include a subtree (never + // prevent a crash), and fast-glob treats `!` ignore entries inconsistently. const gitIgnoreStream = fastGlob.globStream(['**/.gitignore'], { absolute: true, cwd, dot: true, - ignore: DEFAULT_IGNORE_FOR_GIT_IGNORE, + ignore: [ + ...DEFAULT_IGNORE_FOR_GIT_IGNORE, + ...projectIgnoreGlobs, + ...cliMinimatchIgnores, + ] + .filter(p => p.charCodeAt(0) !== 33 /*'!'*/) + .map(stripTrailingSlash), + suppressErrors: true, }) for await (const ignorePatterns of transform( gitIgnoreStream, @@ -273,13 +298,6 @@ export async function globWithGitIgnore( } } - // CLI-supplied `additionalIgnores` are already anchored minimatch — they - // must not pass through the `ignore` package (whose gitignore "match - // anywhere" semantics would re-interpret a bare `tests` to match - // `subdir/tests/foo.json`). Keep them in fast-glob's ignore list across - // both paths; only gitignore-translated entries go into the `ig` matcher. - const cliMinimatchIgnores = additionalIgnores ?? [] - const globOptions = { __proto__: null, absolute: true, @@ -289,6 +307,13 @@ export async function globWithGitIgnore( ? [...defaultIgnore, ...cliMinimatchIgnores] : [...ignores, ...cliMinimatchIgnores].map(stripTrailingSlash), ...additionalOptions, + // Skip directories the running user cannot read rather than aborting the + // whole walk on the first `EACCES` (see the .gitignore discovery walk + // above for the full rationale). Pinned after `...additionalOptions` so a + // caller's options bag cannot accidentally flip it back to `false` and + // re-introduce the crash — `suppressErrors` is a safety invariant here, not + // a tunable. + suppressErrors: true, } as GlobOptions // When no filter is provided and no negated patterns exist, use the fast path. diff --git a/src/utils/glob.test.mts b/src/utils/glob.test.mts index fdec8a636..f403306cd 100644 --- a/src/utils/glob.test.mts +++ b/src/utils/glob.test.mts @@ -1,4 +1,13 @@ -import { existsSync, readdirSync, rmSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -269,6 +278,49 @@ describe('glob utilities', () => { `${mockFixturePath}/package.json`, ]) }) + + // Reproduces the reported `socket fix` crash: a project containing a + // directory the running user cannot enter (e.g. a postgres `pgdata` dir + // owned by another uid, mode drwx------) made fast-glob throw + // `EACCES: permission denied, scandir` during manifest discovery. Uses the + // real filesystem because mock-fs only enforces permissions for non-root + // uids; skipped under root (perm checks bypassed) and on Windows (no POSIX + // directory perms). + const skipUnreadableDirTest = + process.platform === 'win32' || + (typeof process.getuid === 'function' && process.getuid() === 0) + it.skipIf(skipUnreadableDirTest)( + 'skips an unreadable directory instead of throwing EACCES', + async () => { + const realTmp = mkdtempSync(path.join(tmpdir(), 'socket-glob-perm-')) + const unreadable = path.join(realTmp, 'data/postgres/pgdata') + try { + mkdirSync(unreadable, { recursive: true }) + writeFileSync(path.join(realTmp, 'package.json'), '{}') + // Files inside the directory must never surface — the user cannot + // read them, so they cannot be scanned. + writeFileSync(path.join(unreadable, 'PG_VERSION'), '17') + // drwx------ : owner-only, and the running test user is not the owner + // in the field report; locally dropping all bits has the same effect + // of making scandir fail for the current user. + chmodSync(unreadable, 0o000) + + const results = await globWithGitIgnore(['**/*'], { + cwd: realTmp, + }) + + expect(results.map(normalizePath)).toEqual([ + normalizePath(path.join(realTmp, 'package.json')), + ]) + } finally { + // Restore perms so recursive cleanup can descend into the locked dir. + try { + chmodSync(unreadable, 0o755) + } catch {} + rmSync(realTmp, { force: true, recursive: true }) + } + }, + ) }) describe('createSupportedFilesFilter()', () => {