From 5ce4b1ee485c1877369a147ef9671f2fe2208dfe Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 26 Oct 2026 15:46:50 +0100 Subject: [PATCH 1/8] fix: run Bazel Maven scans on Windows Generate Maven manifests with native Windows tools. Preserve paths and arguments, use the scan environment for prerequisite checks, and clean up servers started by the connector. Add pinned Windows fixtures and packaged CLI checks. Assisted-by: Codex:GPT-6 --- .github/workflows/bazel-windows.yml | 112 ++++ CHANGELOG.md | 11 + .../manifest/bazel/bazel-bin-detect.mts | 15 +- .../manifest/bazel/bazel-bin-detect.test.mts | 88 ++- src/commands/manifest/bazel/bazel-cleanup.mts | 111 ++++ src/commands/manifest/bazel/bazel-cquery.mts | 16 +- .../manifest/bazel/bazel-java-shim.mts | 47 +- .../manifest/bazel/bazel-java-shim.test.mts | 92 ++- .../manifest/bazel/bazel-native.e2e.test.mts | 177 ++++++ src/commands/manifest/bazel/bazel-process.mts | 140 +++++ .../manifest/bazel/bazel-process.test.mts | 267 +++++++++ .../manifest/bazel/bazel-python-shim.mts | 129 ++-- .../manifest/bazel/bazel-python-shim.test.mts | 150 ++--- .../manifest/bazel/bazel-query-runner.mts | 67 ++- .../bazel/bazel-query-runner.test.mts | 552 +++++------------- .../manifest/bazel/cmd-manifest-bazel.mts | 20 +- .../manifest/bazel/extract_bazel_to_maven.mts | 125 ++-- ...extract_bazel_to_pypi.constructed.test.mts | 31 +- .../manifest/bazel/extract_bazel_to_pypi.mts | 22 +- .../native/bzlmod/.bazelversion | 1 + .../manifest-bazel/native/bzlmod/BUILD.bazel | 1 + .../manifest-bazel/native/bzlmod/MODULE.bazel | 11 + .../manifest-bazel/native/maven.expected.json | 9 + .../native/workspace/.bazelversion | 1 + .../native/workspace/BUILD.bazel | 1 + .../manifest-bazel/native/workspace/WORKSPACE | 37 ++ 26 files changed, 1445 insertions(+), 788 deletions(-) create mode 100644 .github/workflows/bazel-windows.yml create mode 100644 src/commands/manifest/bazel/bazel-cleanup.mts create mode 100644 src/commands/manifest/bazel/bazel-native.e2e.test.mts create mode 100644 src/commands/manifest/bazel/bazel-process.mts create mode 100644 src/commands/manifest/bazel/bazel-process.test.mts create mode 100644 test/fixtures/manifest-bazel/native/bzlmod/.bazelversion create mode 100644 test/fixtures/manifest-bazel/native/bzlmod/BUILD.bazel create mode 100644 test/fixtures/manifest-bazel/native/bzlmod/MODULE.bazel create mode 100644 test/fixtures/manifest-bazel/native/maven.expected.json create mode 100644 test/fixtures/manifest-bazel/native/workspace/.bazelversion create mode 100644 test/fixtures/manifest-bazel/native/workspace/BUILD.bazel create mode 100644 test/fixtures/manifest-bazel/native/workspace/WORKSPACE diff --git a/.github/workflows/bazel-windows.yml b/.github/workflows/bazel-windows.yml new file mode 100644 index 000000000..89531338a --- /dev/null +++ b/.github/workflows/bazel-windows.yml @@ -0,0 +1,112 @@ +name: Bazel on Windows + +on: + pull_request: + branches: [main, v1.x] + push: + branches: [main, v1.x] + +permissions: + contents: read + +jobs: + maven: + name: Maven on Windows x64 + runs-on: windows-2022 + timeout-minutes: 45 + defaults: + run: + shell: pwsh + steps: + - name: Checkout source + env: + CHECKOUT_REF: ${{ github.sha }} + GITHUB_TOKEN: ${{ github.token }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git init -q + git config --local advice.detachedHead false + git remote add origin "$env:SERVER_URL/$env:REPOSITORY" + $auth = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("x-access-token:$env:GITHUB_TOKEN")) + $env:GIT_CONFIG_COUNT = '1' + $env:GIT_CONFIG_KEY_0 = "http.$env:SERVER_URL/.extraheader" + $env:GIT_CONFIG_VALUE_0 = "AUTHORIZATION: basic $auth" + git fetch --no-tags --depth=1 origin $env:CHECKOUT_REF + git checkout -q --detach FETCH_HEAD + + - name: Install pinned toolchains + run: | + $ErrorActionPreference = 'Stop' + $tools = Join-Path $env:RUNNER_TEMP 'bazel-tools' + New-Item -ItemType Directory -Force "$tools/bin" | Out-Null + function Get-VerifiedTool($url, $file, $sha256) { + Invoke-WebRequest -Uri $url -OutFile $file + if ((Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() -ne $sha256) { + throw "Checksum mismatch for $file" + } + } + Get-VerifiedTool 'https://nodejs.org/dist/v24.14.1/node-v24.14.1-win-x64.zip' "$tools/node.zip" '6e50ce5498c0cebc20fd39ab3ff5df836ed2f8a31aa093cecad8497cff126d70' + Get-VerifiedTool 'https://api.nuget.org/v3-flatcontainer/python/3.12.10/python.3.12.10.nupkg' "$tools/python.zip" '0eb85c2dfccccf1b17352de4c397f69194035b7d37149eacc16f1147d93de3b8' + Get-VerifiedTool 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.6%2B7/OpenJDK21U-jdk_x64_windows_hotspot_21.0.6_7.zip' "$tools/java.zip" '897c8eebb0f85a99ccecbd482ebae9a45d88c19d6077054f6529ebab49b6d259' + Get-VerifiedTool 'https://github.com/bazelbuild/bazelisk/releases/download/v1.26.0/bazelisk-windows-amd64.exe' "$tools/bin/bazelisk.exe" '023734f33ed6b9c6d65468fe20bb2c5fb32473ccb8aca2fc5bf1521e61ce1622' + Get-VerifiedTool 'https://github.com/pnpm/pnpm/releases/download/v10.33.0/pnpm-win-x64.exe' "$tools/bin/pnpm.exe" 'afc96009dc39fe23a835d65192049e6a995f342496b175585dc2beda7d42d33f' + Expand-Archive "$tools/node.zip" "$tools/node" + Expand-Archive "$tools/python.zip" "$tools/python" + Expand-Archive "$tools/java.zip" "$tools/java" + "$tools/node/node-v24.14.1-win-x64" | Add-Content $env:GITHUB_PATH + "$tools/python/tools" | Add-Content $env:GITHUB_PATH + "$tools/java/jdk-21.0.6+7/bin" | Add-Content $env:GITHUB_PATH + "$tools/bin" | Add-Content $env:GITHUB_PATH + "JAVA_HOME=$tools/java/jdk-21.0.6+7" | Add-Content $env:GITHUB_ENV + 'USE_BAZEL_VERSION=7.4.1' | Add-Content $env:GITHUB_ENV + 'SOCKET_BAZEL_OUTPUT_ROOT=C:/sb' | Add-Content $env:GITHUB_ENV + New-Item -ItemType Directory -Force 'C:/sb' | Out-Null + + - name: Record versions and verify prerequisites + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + git rev-parse HEAD + node --version + pnpm --version + bazelisk --version + python --version + java -version + Write-Output "PowerShell $($PSVersionTable.PSVersion)" + Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, OSArchitecture + Get-Command node, bazelisk, python, java | Select-Object Name, Source + + - name: Install dependencies and build + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + pnpm install --frozen-lockfile + pnpm build + + - name: Test executable handling and prerequisites + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + pnpm test:unit src/commands/manifest/bazel/bazel-bin-detect.test.mts src/commands/manifest/bazel/bazel-process.test.mts src/commands/manifest/bazel/bazel-java-shim.test.mts src/commands/manifest/bazel/bazel-python-shim.test.mts src/commands/manifest/bazel/bazel-query-runner.test.mts src/commands/manifest/bazel/bazel-cquery.test.mts + + - name: Install the packaged CLI + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + $packages = Join-Path $env:RUNNER_TEMP 'bazel-packages' + $installation = Join-Path $env:RUNNER_TEMP 'bazel-installation' + pnpm pack --pack-destination $packages + $tarball = Get-ChildItem "$packages/*.tgz" | Select-Object -First 1 + npm install --ignore-scripts --no-audit --no-fund --prefix $installation $tarball.FullName + "SOCKET_BAZEL_TEST_CLI=$installation/node_modules/socket/bin/cli.js" | Add-Content $env:GITHUB_ENV + + - name: Verify Maven manifests and cleanup + env: + SOCKET_BAZEL_INTEGRATION: '1' + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + pnpm exec vitest run --config vitest.e2e.config.mts src/commands/manifest/bazel/bazel-native.e2e.test.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac2e9ef5..b631aeff1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added +- Set `SOCKET_BAZEL_OUTPUT_ROOT` to choose a short parent directory for temporary Bazel output, including on Windows. + +### Fixed +- Bazel Maven extraction handles native Windows executables, batch launchers, and JavaScript entry points while preserving arguments and the selected executable path. +- Bazel prerequisite checks use the extraction environment and verify Python 3. Windows requires a working `python.exe` without creating a Python symlink. +- Quoted Bazel flags preserve spaces and Windows backslashes. Unclosed quotes produce an error. +- Bazel cleanup uses the extraction workspace, environment, and startup options, retries temporary file locks, and leaves caller-owned output bases and their servers alone. + ## [1.10.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.1) - 2026-10-23 ### Changed diff --git a/src/commands/manifest/bazel/bazel-bin-detect.mts b/src/commands/manifest/bazel/bazel-bin-detect.mts index 55f79e1be..4a63279a1 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.mts @@ -1,7 +1,9 @@ import { existsSync } from 'node:fs' +import path from 'node:path' -import { whichBin } from '@socketsecurity/registry/lib/bin' +import { resolveBinPathSync } from '@socketsecurity/registry/lib/bin' +import { findBazelExecutable, getBazelEnvironment } from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' /** @@ -15,21 +17,24 @@ import { InputError } from '../../../utils/errors.mts' */ export async function resolveBazelBinary( explicit: string | undefined, + env: NodeJS.ProcessEnv = getBazelEnvironment(), + cwd: string = process.cwd(), ): Promise { if (explicit) { - if (!existsSync(explicit)) { + const resolved = path.resolve(cwd, explicit) + if (!existsSync(resolved)) { throw new InputError( `--bazel path does not exist: ${explicit}. Install bazelisk or bazel, or pass an existing path via --bazel.`, ) } - return explicit + return resolveBinPathSync(resolved) } // Prefer bazelisk: respects .bazelversion in the workspace. - const bazelisk = await whichBin('bazelisk', { nothrow: true }) + const bazelisk = await findBazelExecutable('bazelisk', env, cwd) if (bazelisk) { return bazelisk } - const bazel = await whichBin('bazel', { nothrow: true }) + const bazel = await findBazelExecutable('bazel', env, cwd) if (bazel) { return bazel } diff --git a/src/commands/manifest/bazel/bazel-bin-detect.test.mts b/src/commands/manifest/bazel/bazel-bin-detect.test.mts index 42edec93c..2f033621e 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.test.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.test.mts @@ -1,53 +1,79 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { copyFileSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' -// Mock whichBin so tests run with no bazel on PATH. -vi.mock('@socketsecurity/registry/lib/bin', () => ({ - whichBin: vi.fn(), -})) - -import { whichBin } from '@socketsecurity/registry/lib/bin' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { getBazelEnvironment } from './bazel-process.mts' describe('resolveBazelBinary', () => { - const mocked = vi.mocked(whichBin) + let cwd: string beforeEach(() => { - mocked.mockReset() + cwd = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'bazel binaries '))) }) - it('returns explicit path when it exists', async () => { - // Use a path that definitely exists on every dev machine. - const existing = process.execPath - await expect(resolveBazelBinary(existing)).resolves.toBe(existing) + afterEach(() => { + rmSync(cwd, { recursive: true, force: true, maxRetries: 5 }) }) - it('throws InputError when explicit path does not exist', async () => { - await expect( - resolveBazelBinary('/no/such/bazel/binary/xyz'), - ).rejects.toThrow(/--bazel path does not exist/) + function install(name: string): string { + const bin = path.join( + cwd, + `${name}${process.platform === 'win32' ? '.exe' : ''}`, + ) + copyFileSync(process.execPath, bin) + return bin + } + + it('resolves an explicit path relative to the scan directory', async () => { + const bin = install('bazel') + const resolved = await resolveBazelBinary( + path.basename(bin), + undefined, + cwd, + ) + expect(path.normalize(resolved)).toBe(bin) }) - it('returns bazelisk when on PATH', async () => { - mocked.mockResolvedValueOnce('/usr/local/bin/bazelisk') - await expect(resolveBazelBinary(undefined)).resolves.toBe( - '/usr/local/bin/bazelisk', + it('preserves an explicit executable outside PATH', async () => { + const bin = install('selected') + const resolved = await resolveBazelBinary( + bin, + getBazelEnvironment({ PATH: '' }), + cwd, ) + expect(path.normalize(resolved)).toBe(bin) }) - it('falls back to bazel when bazelisk is missing', async () => { - mocked - .mockResolvedValueOnce(null) - .mockResolvedValueOnce('/usr/local/bin/bazel') - await expect(resolveBazelBinary(undefined)).resolves.toBe( - '/usr/local/bin/bazel', + it('prefers Bazelisk over Bazel', async () => { + const bazelisk = install('bazelisk') + install('bazel') + const resolved = await resolveBazelBinary( + undefined, + getBazelEnvironment({ PATH: cwd }), + cwd, ) + expect(path.normalize(resolved)).toBe(bazelisk) }) - it('throws InputError when neither is on PATH', async () => { - mocked.mockResolvedValue(null) - await expect(resolveBazelBinary(undefined)).rejects.toThrow( - /Could not find bazelisk or bazel/, + it('falls back to Bazel when Bazelisk is absent', async () => { + const bazel = install('bazel') + const resolved = await resolveBazelBinary( + undefined, + getBazelEnvironment({ PATH: cwd }), + cwd, + ) + expect(path.normalize(resolved)).toBe(bazel) + }) + + it('reports missing tools', async () => { + await expect( + resolveBazelBinary(undefined, getBazelEnvironment({ PATH: cwd }), cwd), + ).rejects.toThrow(/Could not find bazelisk or bazel/) + await expect(resolveBazelBinary(path.join(cwd, 'missing'))).rejects.toThrow( + /--bazel path does not exist/, ) }) }) diff --git a/src/commands/manifest/bazel/bazel-cleanup.mts b/src/commands/manifest/bazel/bazel-cleanup.mts new file mode 100644 index 000000000..30acbb01a --- /dev/null +++ b/src/commands/manifest/bazel/bazel-cleanup.mts @@ -0,0 +1,111 @@ +import { promises as fs, mkdirSync, mkdtempSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { logger } from '@socketsecurity/registry/lib/logger' + +import { + buildBazelStartupFlags, + getBazelEnvironmentValue, + spawnBazel, +} from './bazel-process.mts' +import { getErrorCause } from '../../../utils/errors.mts' + +import type { BazelQueryOptions } from './bazel-query-runner.mts' + +export function makeBazelOutputUserRoot( + env: NodeJS.ProcessEnv, + cwd: string, +): string { + const parent = getBazelEnvironmentValue(env, 'SOCKET_BAZEL_OUTPUT_ROOT') + const resolved = parent ? path.resolve(cwd, parent) : os.tmpdir() + mkdirSync(resolved, { recursive: true }) + return mkdtempSync(path.join(resolved, 'socket-bazel-')) +} + +export async function removeBazelTempdir(dir: string): Promise { + try { + try { + await fs.rm(dir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }) + } catch (e) { + if ( + !(e instanceof Error) || + !('code' in e) || + !['EACCES', 'EPERM'].includes(String(e.code)) + ) { + throw e + } + await makeDirectoriesWritable(dir) + await fs.rm(dir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }) + } + return true + } catch (error) { + logger.warn( + `Bazel temporary directory could not be removed: ${dir}. ${getErrorCause(error)}`, + ) + return false + } +} + +async function makeDirectoriesWritable(root: string): Promise { + const pending = [root] + while (pending.length) { + const directory = pending.pop() + if (!directory) { + continue + } + try { + // Repository rules can unpack read-only directories. Keep links to user files untouched. + // eslint-disable-next-line no-await-in-loop + const stat = await fs.lstat(directory) + if (stat.isSymbolicLink() || !stat.isDirectory()) { + continue + } + // eslint-disable-next-line no-await-in-loop + await fs.chmod(directory, 0o700) + // eslint-disable-next-line no-await-in-loop + const entries = await fs.readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { + pending.push(path.join(directory, entry.name)) + } + } + } catch (e) { + if (!(e instanceof Error) || !('code' in e) || e.code !== 'ENOENT') { + throw e + } + } + } +} + +export async function cleanUpBazelServer( + opts: BazelQueryOptions, +): Promise { + if (!opts.outputUserRoot || opts.bazelOutputBase) { + return true + } + try { + await spawnBazel(opts.bin, [...buildBazelStartupFlags(opts), 'shutdown'], { + cwd: opts.cwd, + ...(opts.env ? { env: opts.env } : {}), + signal: new AbortController().signal, + timeout: 10_000, + }) + return true + } catch (error) { + logger.warn( + `Bazel shutdown failed in ${opts.cwd}. Keeping ${opts.outputUserRoot} for manual cleanup. ${getErrorCause(error)}`, + ) + return false + } +} diff --git a/src/commands/manifest/bazel/bazel-cquery.mts b/src/commands/manifest/bazel/bazel-cquery.mts index f86c6ad49..53f97376a 100644 --- a/src/commands/manifest/bazel/bazel-cquery.mts +++ b/src/commands/manifest/bazel/bazel-cquery.mts @@ -24,8 +24,7 @@ * 6. Tag every artifact with `workspace:` + `repo:` * provenance via `sourceRepo`. */ -import { spawn } from '@socketsecurity/registry/lib/spawn' - +import { buildBazelStartupFlags, spawnBazel } from './bazel-process.mts' import { splitBazelFlags } from './bazel-query-runner.mts' import type { BazelQueryOptions } from './bazel-query-runner.mts' @@ -120,16 +119,7 @@ export function buildMetadataCqueryArgv( repoName: string, opts: BazelQueryOptions, ): string[] { - const startup: string[] = [] - if (opts.bazelRc) { - startup.push(`--bazelrc=${opts.bazelRc}`) - } - if (opts.outputUserRoot) { - startup.push(`--output_user_root=${opts.outputUserRoot}`) - } - if (opts.bazelOutputBase) { - startup.push(`--output_base=${opts.bazelOutputBase}`) - } + const startup = buildBazelStartupFlags(opts) const userFlags = splitBazelFlags(opts.bazelFlags) return [ ...startup, @@ -520,7 +510,7 @@ export async function runMetadataCqueryForRepo( const argv = buildMetadataCqueryArgv(repoName, opts) const startedAt = Date.now() try { - const result = await spawn(opts.bin, argv, { + const result = await spawnBazel(opts.bin, argv, { cwd: workspaceRoot, timeout: timeoutMs, ...(opts.env ? { env: opts.env } : {}), diff --git a/src/commands/manifest/bazel/bazel-java-shim.mts b/src/commands/manifest/bazel/bazel-java-shim.mts index a83a9c390..29e468937 100644 --- a/src/commands/manifest/bazel/bazel-java-shim.mts +++ b/src/commands/manifest/bazel/bazel-java-shim.mts @@ -1,30 +1,31 @@ -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' +import path from 'node:path' -let probed = false +import { + getBazelEnvironment, + getBazelEnvironmentValue, +} from './bazel-process.mts' +import { InputError } from '../../../utils/errors.mts' -// Verifies `java` is functional in the current execution environment. Bazel -// JVM manifest extraction (rules_jvm_external → Coursier) requires a real -// JDK; the CLI does not attempt to discover Homebrew installs or mutate the -// caller's PATH/JAVA_HOME. If `java -version` fails we throw with an -// actionable message so the surfaced error names the prerequisite directly -// instead of relying on Bazel's downstream diagnostic. -export function ensureJavaOnPath(): void { - if (probed) { - return - } +export function ensureJavaOnPath( + env: NodeJS.ProcessEnv = getBazelEnvironment(), + cwd: string = process.cwd(), +): void { + const javaHome = getBazelEnvironmentValue(env, 'JAVA_HOME') + const executable = process.platform === 'win32' ? 'java.exe' : 'java' + const java = javaHome + ? path.resolve(cwd, javaHome, 'bin', executable) + : executable try { - execSync('java -version', { stdio: 'ignore' }) - probed = true + execFileSync(java, ['-version'], { + cwd, + env, + stdio: 'ignore', + timeout: 10_000, + }) } catch { - throw new Error( - 'Java is required for Bazel JVM manifest extraction ' + - '(rules_jvm_external invokes Coursier, which needs a JDK). ' + - 'Install a JDK (e.g. Temurin or OpenJDK) and ensure `java` is on PATH.', + throw new InputError( + 'Java is required for Bazel JVM manifest extraction. Install a JDK and ensure JAVA_HOME points to it or java is on PATH.', ) } } - -// Test-only: clear the per-process cache so tests can re-mock execSync. -export function _resetJavaShimCacheForTests(): void { - probed = false -} diff --git a/src/commands/manifest/bazel/bazel-java-shim.test.mts b/src/commands/manifest/bazel/bazel-java-shim.test.mts index d861b8ea1..a55cd27b4 100644 --- a/src/commands/manifest/bazel/bazel-java-shim.test.mts +++ b/src/commands/manifest/bazel/bazel-java-shim.test.mts @@ -1,72 +1,58 @@ -import { execSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - _resetJavaShimCacheForTests, - ensureJavaOnPath, -} from './bazel-java-shim.mts' +import { ensureJavaOnPath } from './bazel-java-shim.mts' +import { findBazelExecutable, getBazelEnvironment } from './bazel-process.mts' -vi.mock('node:child_process', async () => { - const actual = - await vi.importActual( - 'node:child_process', - ) - return { ...actual, execSync: vi.fn() } -}) +const java = await findBazelExecutable( + 'java', + getBazelEnvironment(), + process.cwd(), +) describe('ensureJavaOnPath', () => { - const mockedExec = vi.mocked(execSync) - - let originalJavaHome: string | undefined - let originalPath: string | undefined + let cwd: string beforeEach(() => { - mockedExec.mockReset() - _resetJavaShimCacheForTests() - originalJavaHome = process.env['JAVA_HOME'] - originalPath = process.env['PATH'] + cwd = mkdtempSync(path.join(os.tmpdir(), 'bazel java ')) }) afterEach(() => { - if (originalJavaHome === undefined) { - delete process.env['JAVA_HOME'] - } else { - process.env['JAVA_HOME'] = originalJavaHome - } - process.env['PATH'] = originalPath + rmSync(cwd, { recursive: true, force: true }) }) - it('returns silently and leaves the environment untouched when java is on PATH', () => { - mockedExec.mockReturnValue(Buffer.from('')) - expect(() => ensureJavaOnPath()).not.toThrow() - expect(process.env['JAVA_HOME']).toBe(originalJavaHome) - expect(process.env['PATH']).toBe(originalPath) + it.skipIf(!java)('runs the installed Java executable', () => { + expect(() => ensureJavaOnPath(getBazelEnvironment(), cwd)).not.toThrow() }) - it('throws an actionable error when java is missing', () => { - mockedExec.mockImplementation(() => { - throw new Error('java not found') - }) - expect(() => ensureJavaOnPath()).toThrow(/Java is required/) - // No env mutation on failure. - expect(process.env['JAVA_HOME']).toBe(originalJavaHome) - expect(process.env['PATH']).toBe(originalPath) + it('reports missing Java in the supplied environment', () => { + expect(() => + ensureJavaOnPath(getBazelEnvironment({ PATH: cwd, JAVA_HOME: '' }), cwd), + ).toThrow(/Java is required/) }) - it('is idempotent on success: subsequent calls do not re-probe', () => { - mockedExec.mockReturnValue(Buffer.from('')) - ensureJavaOnPath() - ensureJavaOnPath() - expect(mockedExec).toHaveBeenCalledTimes(1) + it('checks the supplied JAVA_HOME before PATH', () => { + expect(() => + ensureJavaOnPath( + getBazelEnvironment({ JAVA_HOME: path.join(cwd, 'missing jdk') }), + cwd, + ), + ).toThrow(/JAVA_HOME/) }) - it('re-throws on every call when java remains missing', () => { - mockedExec.mockImplementation(() => { - throw new Error('java not found') - }) - expect(() => ensureJavaOnPath()).toThrow(/Java is required/) - expect(() => ensureJavaOnPath()).toThrow(/Java is required/) - expect(mockedExec).toHaveBeenCalledTimes(2) - }) + it.skipIf(!java)( + 'does not reuse a successful check with a different environment', + () => { + ensureJavaOnPath(getBazelEnvironment(), cwd) + expect(() => + ensureJavaOnPath( + getBazelEnvironment({ PATH: cwd, JAVA_HOME: '' }), + cwd, + ), + ).toThrow(/Java is required/) + }, + ) }) diff --git a/src/commands/manifest/bazel/bazel-native.e2e.test.mts b/src/commands/manifest/bazel/bazel-native.e2e.test.mts new file mode 100644 index 000000000..78d799bdd --- /dev/null +++ b/src/commands/manifest/bazel/bazel-native.e2e.test.mts @@ -0,0 +1,177 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { ensureJavaOnPath } from './bazel-java-shim.mts' +import { getBazelEnvironment, spawnBazel } from './bazel-process.mts' +import { provisionPythonShim } from './bazel-python-shim.mts' +import { extractBazelToMaven } from './extract_bazel_to_maven.mts' + +const FIXTURES = path.resolve( + import.meta.dirname, + '../../../../test/fixtures/manifest-bazel/native', +) +const EXPECTED: unknown = JSON.parse( + readFileSync(path.join(FIXTURES, 'maven.expected.json'), 'utf8'), +) +const CLI = + process.env['SOCKET_BAZEL_TEST_CLI'] ?? + path.resolve(import.meta.dirname, '../../../../bin/cli.js') + +describe.skipIf(process.env['SOCKET_BAZEL_INTEGRATION'] !== '1')( + 'native Bazel Maven extraction', + () => { + let scratch: string + let outputParent: string + let bin: string + + beforeAll(async () => { + bin = await resolveBazelBinary(process.env['SOCKET_BAZEL_TEST_BIN']) + ensureJavaOnPath() + const python = await provisionPythonShim() + if (python.shimDir) { + rmSync(python.shimDir, { recursive: true, force: true }) + } + expect(existsSync(CLI)).toBe(true) + const version = await spawnBazel(bin, ['--version'], { + cwd: path.join(FIXTURES, 'bzlmod'), + }) + console.log({ + bazel: version.stdout.trim(), + bin, + cli: CLI, + node: process.version, + platform: process.platform, + arch: process.arch, + }) + }) + + beforeEach(() => { + scratch = realpathSync( + mkdtempSync(path.join(os.tmpdir(), 'bazel native ')), + ) + const parent = process.env['SOCKET_BAZEL_OUTPUT_ROOT'] ?? os.tmpdir() + mkdirSync(parent, { recursive: true }) + outputParent = mkdtempSync(path.join(parent, 'native-')) + }) + + afterEach(() => { + rmSync(scratch, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }) + rmSync(outputParent, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }) + }) + + for (const mode of ['bzlmod', 'workspace']) { + const manifestName = + mode === 'bzlmod' + ? 'socket_fixture_maven_install.json' + : 'maven_install.json' + it(`extracts direct and transitive Maven dependencies in ${mode} mode`, async () => { + const workspace = path.join(scratch, 'project with spaces') + cpSync(path.join(FIXTURES, mode), workspace, { recursive: true }) + const marker = path.join( + workspace, + mode === 'bzlmod' ? 'MODULE.bazel' : 'WORKSPACE', + ) + const original = readFileSync(marker, 'utf8') + const out = path.join(scratch, 'generated manifests') + const result = await extractBazelToMaven({ + bazelFlags: '--repo_env=FIXTURE_PATH="C:\\Program Files\\fixture"', + bazelOutputBase: undefined, + bazelRc: undefined, + bin, + cwd: scratch, + out, + env: { SOCKET_BAZEL_OUTPUT_ROOT: outputParent }, + verbose: true, + }) + expect(result.status).toBe('complete') + expect(result.artifactCount).toBe(2) + expect(result.manifestPaths).toHaveLength(1) + const manifest = result.manifestPaths[0] + if (!manifest) { + throw new Error('Expected a Maven manifest') + } + expect(JSON.parse(readFileSync(manifest, 'utf8'))).toMatchObject( + EXPECTED, + ) + expect(readFileSync(marker, 'utf8')).toBe(original) + expect(existsSync(path.join(workspace, 'MODULE.bazel.lock'))).toBe( + false, + ) + expect(readdirSync(outputParent)).toEqual([]) + }, 600_000) + + it(`runs the packaged CLI in ${mode} mode`, async () => { + const workspace = path.join(scratch, 'packaged project') + cpSync(path.join(FIXTURES, mode), workspace, { recursive: true }) + const out = path.join(scratch, 'packaged output') + const result = await spawn( + process.execPath, + [CLI, 'manifest', 'bazel', '--bazel', bin, '--out', out, workspace], + { + cwd: scratch, + env: getBazelEnvironment({ + SOCKET_BAZEL_OUTPUT_ROOT: outputParent, + }), + timeout: 600_000, + }, + ) + expect(result.code).toBe(0) + expect( + JSON.parse(readFileSync(path.join(out, manifestName), 'utf8')), + ).toMatchObject(EXPECTED) + expect(readdirSync(outputParent)).toEqual([]) + }, 600_000) + + it(`uses the Maven auto-manifest layout in ${mode} mode`, async () => { + const workspace = path.join(scratch, 'auto project') + cpSync(path.join(FIXTURES, mode), workspace, { recursive: true }) + const result = await extractBazelToMaven({ + bazelFlags: undefined, + bazelOutputBase: undefined, + bazelRc: undefined, + bin, + cwd: workspace, + out: scratch, + outLayout: 'flat', + env: { SOCKET_BAZEL_OUTPUT_ROOT: outputParent }, + verbose: true, + }) + expect(result.status).toBe('complete') + expect( + JSON.parse( + readFileSync( + path.join(scratch, '.socket-auto-manifest', manifestName), + 'utf8', + ), + ), + ).toMatchObject(EXPECTED) + expect(readdirSync(outputParent)).toEqual([]) + }, 600_000) + } + }, +) diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts new file mode 100644 index 000000000..b3fa2c38b --- /dev/null +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -0,0 +1,140 @@ +import { closeSync, existsSync, openSync, readSync } from 'node:fs' +import path from 'node:path' + +import { whichBin } from '@socketsecurity/registry/lib/bin' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { InputError } from '../../../utils/errors.mts' + +import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' + +export function getBazelEnvironment( + overrides: NodeJS.ProcessEnv = {}, + platform: NodeJS.Platform = process.platform, +): NodeJS.ProcessEnv { + const env = { ...process.env } + for (const [key, value] of Object.entries(overrides)) { + const existing = + platform === 'win32' + ? Object.keys(env).find( + name => name.toLowerCase() === key.toLowerCase(), + ) + : undefined + env[existing ?? key] = value + } + return env +} + +export function getBazelEnvironmentValue( + env: NodeJS.ProcessEnv, + name: string, +): string | undefined { + const key = + process.platform === 'win32' + ? Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) + : name + return key ? env[key] : undefined +} + +export async function findBazelExecutable( + name: string, + env: NodeJS.ProcessEnv, + cwd: string, +): Promise { + const searchPath = (getBazelEnvironmentValue(env, 'PATH') ?? '') + .split(path.delimiter) + .map(entry => path.resolve(cwd, entry)) + .join(path.delimiter) + try { + return await whichBin(name, { + path: searchPath, + pathExt: getBazelEnvironmentValue(env, 'PATHEXT'), + }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } +} + +function isNodeScript(bin: string): boolean { + if (/\.[cm]?js$/i.test(bin)) { + return true + } + if (path.extname(bin) || process.platform !== 'win32' || !existsSync(bin)) { + return false + } + const fd = openSync(bin, 'r') + try { + const header = Buffer.alloc(256) + const length = readSync(fd, header) + return /^#![^\r\n]*\bnode(?:\s|$)/.test(header.toString('utf8', 0, length)) + } finally { + closeSync(fd) + } +} + +function escapeBatchArgument(value: string): string { + if (/[\r\n\0]/.test(value)) { + throw new InputError( + 'Bazel batch launchers cannot accept arguments containing newlines or NUL characters. Use a native .exe launcher.', + ) + } + const quoted = `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"` + // Batch forwarding parses arguments twice, so protect each shell metacharacter twice. + return quoted + .replace(/[ !%^&()<>|"]/g, '^$&') + .replace(/[ !%^&()<>|"]/g, '^$&') +} + +export function buildBazelProcessInvocation( + bin: string, + args: string[], + env: NodeJS.ProcessEnv, +): { command: string; args: string[]; windowsVerbatimArguments?: boolean } { + if (isNodeScript(bin)) { + return { command: process.execPath, args: [bin, ...args] } + } + if (process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(bin)) { + const command = bin.replace(/[ !%^&()<>|"]/g, '^$&') + const script = [command, ...args.map(escapeBatchArgument)].join(' ') + return { + command: getBazelEnvironmentValue(env, 'ComSpec') ?? 'cmd.exe', + args: ['/d', '/v:off', '/s', '/c', `"${script}"`], + windowsVerbatimArguments: true, + } + } + return { command: bin, args } +} + +export function spawnBazel(bin: string, args: string[], options: SpawnOptions) { + const env = getBazelEnvironment(options.env) + const invocation = buildBazelProcessInvocation(bin, args, env) + return spawn(invocation.command, invocation.args, { + ...options, + ...('windowsVerbatimArguments' in invocation + ? { windowsVerbatimArguments: invocation.windowsVerbatimArguments } + : {}), + env, + shell: false, + }) +} + +export function buildBazelStartupFlags(opts: { + bazelRc?: string + bazelOutputBase?: string + outputUserRoot?: string +}): string[] { + const startup: string[] = [] + if (opts.bazelRc) { + startup.push(`--bazelrc=${opts.bazelRc}`) + } + if (opts.outputUserRoot) { + startup.push(`--output_user_root=${opts.outputUserRoot}`) + } + if (opts.bazelOutputBase) { + startup.push(`--output_base=${opts.bazelOutputBase}`) + } + return startup +} diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts new file mode 100644 index 000000000..d049255c0 --- /dev/null +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -0,0 +1,267 @@ +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import cmdShim from 'cmd-shim' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { + cleanUpBazelServer, + makeBazelOutputUserRoot, + removeBazelTempdir, +} from './bazel-cleanup.mts' +import { getBazelEnvironment, spawnBazel } from './bazel-process.mts' +import { splitBazelFlags } from './bazel-query-runner.mts' + +const ARGUMENTS = [ + '', + 'attr("tags", "\\bmaven_coordinates=", @maven//...)', + '--bazelrc=C:\\Program Files\\Bazel\\custom.bazelrc', + 'C:\\path with spaces\\', + 'quotes "inside" values', + '&echo injected>bad.txt', + 'literal|pipe<>!^()', + '%SOCKET_BAZEL_LITERAL%', + '$HOME `command` $(command)', + 'unicodé', +] + +const LAUNCHER = `const fs = require('node:fs') +const record = { args: process.argv.slice(2), cwd: process.cwd(), marker: process.env.SOCKET_BAZEL_LITERAL } +if (process.env.SOCKET_BAZEL_RECORD) { fs.writeFileSync(process.env.SOCKET_BAZEL_RECORD, JSON.stringify(record)) } +process.stdout.write(JSON.stringify(record)) +` + +describe('Bazel process invocation', () => { + let cwd: string + let launcher: string + + beforeEach(() => { + cwd = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'bazel process '))) + launcher = path.join(cwd, 'launcher.cjs') + writeFileSync(launcher, LAUNCHER) + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true, maxRetries: 5 }) + }) + + it('passes arguments unchanged to a native executable', async () => { + const executable = path.join( + cwd, + process.platform === 'win32' ? 'native launcher.exe' : 'native launcher', + ) + copyFileSync(process.execPath, executable) + const result = await spawnBazel(executable, [launcher, ...ARGUMENTS], { + cwd, + }) + expect(JSON.parse(result.stdout)).toMatchObject({ args: ARGUMENTS, cwd }) + }) + + it('invokes an explicit JavaScript entry point with Node', async () => { + const bin = await resolveBazelBinary( + launcher, + getBazelEnvironment({ PATH: '' }), + cwd, + ) + const result = await spawnBazel(bin, ARGUMENTS, { + cwd, + env: { SOCKET_BAZEL_LITERAL: 'from supplied environment' }, + }) + expect(JSON.parse(result.stdout)).toEqual({ + args: ARGUMENTS, + cwd, + marker: 'from supplied environment', + }) + }) + + it('runs a launcher generated by npm cmd-shim', async () => { + writeFileSync(launcher, `#!/usr/bin/env node\n${LAUNCHER}`) + const shim = path.join(cwd, 'bazelisk') + await cmdShim(launcher, shim) + const bin = await resolveBazelBinary( + process.platform === 'win32' ? `${shim}.cmd` : shim, + ) + const result = await spawnBazel(bin, ARGUMENTS, { cwd }) + expect(JSON.parse(result.stdout)).toMatchObject({ args: ARGUMENTS, cwd }) + }) + + for (const extension of ['cmd', 'bat']) { + it.skipIf(process.platform !== 'win32')( + `preserves arguments through an explicit ${extension} launcher outside PATH`, + async () => { + const wrapper = path.join(cwd, `outside PATH.${extension}`) + writeFileSync( + wrapper, + `@echo off\r\n"${process.execPath}" "${launcher}" %*\r\n`, + ) + const result = await spawnBazel(wrapper, ARGUMENTS, { + cwd, + env: { SOCKET_BAZEL_LITERAL: 'must not expand' }, + }) + expect(JSON.parse(result.stdout)).toEqual({ + args: ARGUMENTS, + cwd, + marker: 'must not expand', + }) + }, + ) + } + + it('normalizes Windows environment key casing', () => { + const env = getBazelEnvironment( + { Path: 'supplied path', PATH: 'last value' }, + 'win32', + ) + const keys = Object.keys(env).filter(key => key.toLowerCase() === 'path') + expect(keys).toHaveLength(1) + const key = keys[0] + expect(key && env[key]).toBe('last value') + }) + + it('shuts down with the same workspace, startup flags, and environment', async () => { + const record = path.join(cwd, 'shutdown.json') + const root = makeBazelOutputUserRoot( + getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), + cwd, + ) + expect( + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + env: { + SOCKET_BAZEL_LITERAL: 'shutdown environment', + SOCKET_BAZEL_RECORD: record, + }, + }), + ).toBe(true) + expect(JSON.parse(readFileSync(record, 'utf8'))).toEqual({ + args: [ + `--bazelrc=${path.join(cwd, 'custom rc')}`, + `--output_user_root=${root}`, + 'shutdown', + ], + cwd, + marker: 'shutdown environment', + }) + expect(await removeBazelTempdir(root)).toBe(true) + }) + + it('leaves caller-owned output bases and their servers alone', async () => { + expect( + await cleanUpBazelServer({ + bin: path.join(cwd, 'must not run'), + cwd, + invocationFlags: [], + outputUserRoot: cwd, + bazelOutputBase: path.join(cwd, 'caller cache'), + }), + ).toBe(true) + }) + + it('returns a cleanup failure when shutdown fails', async () => { + writeFileSync(launcher, 'process.exit(1)\n') + expect( + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: cwd, + }), + ).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'removes read-only directories without changing linked user data', + async () => { + const root = makeBazelOutputUserRoot( + getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), + cwd, + ) + const readonly = path.join(root, 'read-only repository') + mkdirSync(readonly) + writeFileSync(path.join(readonly, 'data'), 'owned') + const userDirectory = path.join(cwd, 'user files') + mkdirSync(userDirectory) + writeFileSync(path.join(userDirectory, 'keep'), 'user data') + chmodSync(userDirectory, 0o750) + symlinkSync(userDirectory, path.join(readonly, 'user link')) + chmodSync(readonly, 0o500) + expect(await removeBazelTempdir(root)).toBe(true) + expect(readFileSync(path.join(userDirectory, 'keep'), 'utf8')).toBe( + 'user data', + ) + expect(statSync(userDirectory).mode & 0o777).toBe(0o750) + }, + ) + + it('bounds a real subprocess with a timeout', async () => { + writeFileSync(launcher, 'setInterval(() => {}, 1000)\n') + await expect( + spawnBazel(launcher, [], { cwd, timeout: 100 }), + ).rejects.toMatchObject({ signal: 'SIGTERM' }) + }) + + it('cancels a real subprocess', async () => { + writeFileSync(launcher, 'setInterval(() => {}, 1000)\n') + const controller = new AbortController() + const result = spawnBazel(launcher, [], { cwd, signal: controller.signal }) + controller.abort() + await expect(result).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +describe('splitBazelFlags', () => { + it('handles existing unquoted flags and CRLF', () => { + expect(splitBazelFlags('--config=ci\r\n --keep_going')).toEqual([ + '--config=ci', + '--keep_going', + ]) + expect(splitBazelFlags(undefined)).toEqual([]) + }) + + it('keeps quoted paths and Windows backslashes', () => { + expect( + splitBazelFlags( + '--define="data=C:\\Program Files\\data\\" --config=\'Windows CI\'', + ), + ).toEqual([ + '--define=data=C:\\Program Files\\data\\', + '--config=Windows CI', + ]) + }) + + it('preserves empty arguments and literal shell characters', () => { + expect(splitBazelFlags('"" --define="x=$HOME & %PATH%"')).toEqual([ + '', + '--define=x=$HOME & %PATH%', + ]) + }) + + it('accepts doubled quotes within a quoted value', () => { + expect(splitBazelFlags('--define="a=""quoted"""')).toEqual([ + '--define=a="quoted"', + ]) + }) + + it('reports malformed quoting', () => { + expect(() => splitBazelFlags('--define="missing end')).toThrow( + /unterminated quote/, + ) + }) +}) diff --git a/src/commands/manifest/bazel/bazel-python-shim.mts b/src/commands/manifest/bazel/bazel-python-shim.mts index 8517fce14..86279b947 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.mts @@ -1,89 +1,84 @@ -import { existsSync, mkdirSync, symlinkSync, unlinkSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, symlinkSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { whichBin } from '@socketsecurity/registry/lib/bin' - +import { + findBazelExecutable, + getBazelEnvironment, + getBazelEnvironmentValue, +} from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' export type PythonShimResult = { - // PATH-prefixed env to pass into spawn opts. undefined if no shim needed. augmentedEnv: NodeJS.ProcessEnv | undefined shimDir: string | undefined } -// Stable shim dir name — same process will get the same dir; concurrent -// socket-cli invocations on the same machine share it. The symlink target -// is whatever python3 resolves to NOW; if PATH changes between invocations -// we replace the symlink. -const SHIM_SUBDIR = 'socket-cli-bazel-python-shim' - -// Cache the result for the lifetime of this process. -let cached: PythonShimResult | null = null - -// Safe wrapper around whichBin that returns null instead of throwing when -// nothrow semantics are broken in older registry versions (realpath 'null' bug). -async function safeWhichBin(name: string): Promise { +function verifyPython( + executable: string, + env: NodeJS.ProcessEnv, + cwd: string, +): void { try { - return (await whichBin(name, { nothrow: true })) ?? null + const version = execFileSync( + executable, + ['-c', 'import sys\nprint(sys.version_info.major)'], + { + cwd, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }, + ) + if (version.trim() === '3') { + return + } } catch { - return null + // Windows application aliases can resolve without a working interpreter. } + throw new InputError( + `Python 3 is required for Bazel manifest extraction. ${executable} did not run as Python 3. Install Python 3 and ensure python${process.platform === 'win32' ? '.exe' : ''} is on PATH.`, + ) } -export async function provisionPythonShim(): Promise { - if (cached) { - return cached - } - const pythonOnPath = await safeWhichBin('python') - if (pythonOnPath) { - cached = { augmentedEnv: undefined, shimDir: undefined } - return cached +export async function provisionPythonShim( + env: NodeJS.ProcessEnv = getBazelEnvironment(), + cwd: string = process.cwd(), +): Promise { + const python = await findBazelExecutable('python', env, cwd) + if (python) { + if ( + process.platform === 'win32' && + path.extname(python).toLowerCase() !== '.exe' + ) { + throw new InputError( + 'Bazel on Windows requires a working Python 3 python.exe on PATH. Python batch launchers are not supported.', + ) + } + verifyPython(python, env, cwd) + return { augmentedEnv: undefined, shimDir: undefined } } - const python3OnPath = await safeWhichBin('python3') - if (!python3OnPath) { + if (process.platform === 'win32') { throw new InputError( - 'Neither `python` nor `python3` found on PATH. Older versions of ' + - 'rules_jvm_external require a `python` interpreter for repository ' + - 'rules. Install Python 3 and ensure it is on PATH, then retry.', + 'Bazel on Windows requires a working Python 3 python.exe on PATH. Install Python 3 with the PATH option enabled, then retry.', ) } - const shimDir = path.join(os.tmpdir(), SHIM_SUBDIR) - mkdirSync(shimDir, { recursive: true }) - const linkPath = path.join(shimDir, 'python') - // Replace the symlink defensively in case python3's resolved path moved. - if (existsSync(linkPath)) { - try { - unlinkSync(linkPath) - } catch { - // Tolerate races; the next symlinkSync may still succeed. - } - } - // The shim dir is process-shared (os.tmpdir()/socket-cli-bazel-python-shim), - // so a concurrent socket-cli invocation may re-create the link between our - // unlinkSync and symlinkSync. Tolerate EEXIST when the link is back: the - // other process won the race and left a usable shim in place. - try { - symlinkSync(python3OnPath, linkPath) - } catch (e) { - if ( - (e as NodeJS.ErrnoException).code === 'EEXIST' && - existsSync(linkPath) - ) { - // Another process re-created the link; assume it points at a python3. - } else { - throw e - } + const python3 = await findBazelExecutable('python3', env, cwd) + if (!python3) { + throw new InputError( + 'Neither python nor python3 was found on PATH. Install Python 3 and ensure it is on PATH, then retry.', + ) } - const augmentedEnv = { - ...process.env, - PATH: `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + verifyPython(python3, env, cwd) + const shimDir = mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-python-')) + symlinkSync(python3, path.join(shimDir, 'python')) + return { + augmentedEnv: getBazelEnvironment({ + ...env, + PATH: `${shimDir}${path.delimiter}${getBazelEnvironmentValue(env, 'PATH') ?? ''}`, + }), + shimDir, } - cached = { augmentedEnv, shimDir } - return cached -} - -// Test-only: clear the per-process cache so tests can re-mock whichBin. -export function _resetPythonShimCacheForTests(): void { - cached = null } diff --git a/src/commands/manifest/bazel/bazel-python-shim.test.mts b/src/commands/manifest/bazel/bazel-python-shim.test.mts index 753aceb92..157580f10 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.test.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.test.mts @@ -1,91 +1,103 @@ -import { existsSync, readlinkSync, rmSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdtempSync, + readlinkSync, + rmSync, + symlinkSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@socketsecurity/registry/lib/bin', () => ({ - whichBin: vi.fn(), -})) +import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { whichBin } from '@socketsecurity/registry/lib/bin' +import { findBazelExecutable, getBazelEnvironment } from './bazel-process.mts' +import { provisionPythonShim } from './bazel-python-shim.mts' -import { - _resetPythonShimCacheForTests, - provisionPythonShim, -} from './bazel-python-shim.mts' +const python = await findBazelExecutable( + process.platform === 'win32' ? 'python' : 'python3', + getBazelEnvironment(), + process.cwd(), +) describe('provisionPythonShim', () => { - const mocked = vi.mocked(whichBin) - const SHIM_DIR = path.join(os.tmpdir(), 'socket-cli-bazel-python-shim') + let cwd: string + const shims: string[] = [] beforeEach(() => { - mocked.mockReset() - _resetPythonShimCacheForTests() + cwd = mkdtempSync(path.join(os.tmpdir(), 'bazel python ')) }) afterEach(() => { - // Clean up the shared shim dir between tests to avoid stale symlinks. - if (existsSync(SHIM_DIR)) { - rmSync(SHIM_DIR, { recursive: true, force: true }) + for (const dir of [cwd, ...shims.splice(0)]) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) } }) - it('returns no-shim result when python is already on PATH', async () => { - mocked.mockResolvedValueOnce('/usr/bin/python') - const result = await provisionPythonShim() - expect(result).toEqual({ augmentedEnv: undefined, shimDir: undefined }) - }) - - it('creates python symlink shim when only python3 is on PATH', async () => { - mocked - .mockResolvedValueOnce(null) // python - .mockResolvedValueOnce(process.execPath) // python3 - const result = await provisionPythonShim() - expect(result.shimDir).toBe(SHIM_DIR) - expect(existsSync(path.join(SHIM_DIR, 'python'))).toBe(true) - expect(readlinkSync(path.join(SHIM_DIR, 'python'))).toBe(process.execPath) - const pathValue = result.augmentedEnv?.['PATH'] ?? '' - expect(pathValue.startsWith(SHIM_DIR)).toBe(true) + it('reports missing Python in the supplied environment', async () => { + await expect( + provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), + ).rejects.toThrow(/python.exe|Neither python nor python3/) }) - it('throws InputError when neither python nor python3 is found on PATH', async () => { - mocked.mockResolvedValue(null) - await expect(provisionPythonShim()).rejects.toThrow( - /Neither .python. nor .python3/, + it('rejects an executable that does not run Python 3', async () => { + copyFileSync( + process.execPath, + path.join(cwd, process.platform === 'win32' ? 'python.exe' : 'python'), ) + await expect( + provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), + ).rejects.toThrow(/did not run as Python 3/) }) - it('is idempotent: returns cached result on second call', async () => { - mocked - .mockResolvedValueOnce(null) // python - .mockResolvedValueOnce(process.execPath) // python3 - const a = await provisionPythonShim() - const b = await provisionPythonShim() - // Same object reference — cached. - expect(b).toBe(a) - // whichBin only called twice (for the first invocation). - expect(mocked).toHaveBeenCalledTimes(2) - }) + it.skipIf(!python)( + 'checks a working interpreter in the supplied environment', + async () => { + if (!python) { + throw new Error('Python fixture is unavailable') + } + if (process.platform === 'win32') { + const result = await provisionPythonShim( + getBazelEnvironment({ Path: path.dirname(python) }), + cwd, + ) + expect(result).toEqual({ augmentedEnv: undefined, shimDir: undefined }) + } else { + symlinkSync(python, path.join(cwd, 'python')) + expect( + await provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), + ).toEqual({ augmentedEnv: undefined, shimDir: undefined }) + } + await expect( + provisionPythonShim( + getBazelEnvironment({ PATH: path.join(cwd, 'missing') }), + cwd, + ), + ).rejects.toThrow(/python/) + }, + ) - it('uses a stable shim dir under os.tmpdir()', async () => { - mocked - .mockResolvedValueOnce(null) // python - .mockResolvedValueOnce(process.execPath) // python3 - const result = await provisionPythonShim() - expect(result.shimDir).toBe( - path.join(os.tmpdir(), 'socket-cli-bazel-python-shim'), - ) - }) - - it('symlink target is the absolute resolved python3 path', async () => { - mocked - .mockResolvedValueOnce(null) // python - .mockResolvedValueOnce(process.execPath) // python3 - await provisionPythonShim() - const linkTarget = readlinkSync(path.join(SHIM_DIR, 'python')) - // Must be an absolute path. - expect(path.isAbsolute(linkTarget)).toBe(true) - expect(linkTarget).toBe(process.execPath) - }) + it.skipIf(!python || process.platform === 'win32')( + 'creates separate Unix shims for concurrent calls', + async () => { + if (!python) { + throw new Error('Python fixture is unavailable') + } + symlinkSync(python, path.join(cwd, 'python3')) + const results = await Promise.all([ + provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), + provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), + ]) + for (const result of results) { + if (!result.shimDir) { + throw new Error('Expected a Python shim') + } + shims.push(result.shimDir) + expect(existsSync(path.join(result.shimDir, 'python'))).toBe(true) + expect(readlinkSync(path.join(result.shimDir, 'python'))).toBe(python) + expect(result.augmentedEnv?.['PATH']).toContain(cwd) + } + expect(results[0]?.shimDir).not.toBe(results[1]?.shimDir) + }, + ) }) diff --git a/src/commands/manifest/bazel/bazel-query-runner.mts b/src/commands/manifest/bazel/bazel-query-runner.mts index c9ef84f1a..736aea40f 100644 --- a/src/commands/manifest/bazel/bazel-query-runner.mts +++ b/src/commands/manifest/bazel/bazel-query-runner.mts @@ -1,7 +1,11 @@ import { logger } from '@socketsecurity/registry/lib/logger' -import { spawn } from '@socketsecurity/registry/lib/spawn' +import { + buildBazelStartupFlags as buildStartupFlags, + spawnBazel, +} from './bazel-process.mts' import constants from '../../../constants.mts' +import { InputError } from '../../../utils/errors.mts' import type { RepoProbe } from './bazel-repo-discovery.mts' @@ -35,33 +39,50 @@ const BAZEL_QUERY_TIMEOUT_MS = 600_000 const STDERR_TAIL_BYTES = 4_096 const STDOUT_EXCERPT_BYTES = 1_024 -// Splits the user-supplied --bazel-flags string on whitespace. -// Empty / undefined returns []. No shell parsing — quoted args with embedded -// whitespace are not supported (documented limitation; same trust model as -// gradleOpts). export function splitBazelFlags(flags: string | undefined): string[] { if (!flags) { return [] } - return flags.split(/\s+/).filter(Boolean) -} - -// Build the shared startup-flag prefix for any bazel invocation. Centralised -// so `--output_user_root` propagates to every spawn — principle 7 of the -// Maven design requires per-invocation server isolation across query, -// cquery, and `bazel mod` commands alike. -function buildStartupFlags(opts: BazelQueryOptions): string[] { - const startup: string[] = [] - if (opts.bazelRc) { - startup.push(`--bazelrc=${opts.bazelRc}`) + const args: string[] = [] + let argument = '' + let quote: string | undefined + let started = false + for (let index = 0; index < flags.length; index += 1) { + const character = flags.charAt(index) + if (quote) { + if (character === quote) { + if (flags.charAt(index + 1) === quote) { + argument += quote + index += 1 + } else { + quote = undefined + } + } else { + argument += character + } + } else if (character === '"' || character === "'") { + quote = character + started = true + } else if (/\s/.test(character)) { + if (started) { + args.push(argument) + argument = '' + started = false + } + } else { + argument += character + started = true + } } - if (opts.outputUserRoot) { - startup.push(`--output_user_root=${opts.outputUserRoot}`) + if (quote) { + throw new InputError( + '--bazel-flags contains an unterminated quote. Close each quoted value. Backslashes are preserved literally.', + ) } - if (opts.bazelOutputBase) { - startup.push(`--output_base=${opts.bazelOutputBase}`) + if (started) { + args.push(argument) } - return startup + return args } function buildBazelModShowVisibleReposArgv(opts: BazelQueryOptions): string[] { @@ -252,7 +273,7 @@ export async function runBazelQuery( let result: BazelQueryResult | undefined try { spinner.start(`Running bazel query (${queryStr.slice(0, 80)})...`) - const output = await spawn(opts.bin, argv, { + const output = await spawnBazel(opts.bin, argv, { cwd: opts.cwd, timeout: BAZEL_QUERY_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}), @@ -293,7 +314,7 @@ async function runBazelOneShot( const startedAt = Date.now() let result: BazelQueryResult try { - const output = await spawn(opts.bin, argv, { + const output = await spawnBazel(opts.bin, argv, { cwd: opts.cwd, timeout: BAZEL_QUERY_TIMEOUT_MS, ...(opts.env ? { env: opts.env } : {}), diff --git a/src/commands/manifest/bazel/bazel-query-runner.test.mts b/src/commands/manifest/bazel/bazel-query-runner.test.mts index c4e526f40..b82739444 100644 --- a/src/commands/manifest/bazel/bazel-query-runner.test.mts +++ b/src/commands/manifest/bazel/bazel-query-runner.test.mts @@ -1,24 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' -vi.mock('@socketsecurity/registry/lib/spawn', () => ({ - spawn: vi.fn(), -})) - -// Mock the spinner so tests don't render to TTY. -vi.mock('../../../constants.mts', () => ({ - default: { - spinner: { - start: vi.fn(), - successAndStop: vi.fn(), - failAndStop: vi.fn(), - }, - }, -})) - -import { logger } from '@socketsecurity/registry/lib/logger' -import { spawn } from '@socketsecurity/registry/lib/spawn' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { buildMetadataCqueryArgv } from './bazel-cquery.mts' import { + _internalArgvBuilders, buildMavenProbeFor, buildPypiProbeFor, runBazelModShowMavenExtension, @@ -26,427 +14,187 @@ import { runBazelModShowVisibleRepos, runBazelQuery, } from './bazel-query-runner.mts' -import constants from '../../../constants.mts' - -describe('runBazelQuery', () => { - const mocked = vi.mocked(spawn) - - beforeEach(() => { - mocked.mockReset() - vi.mocked(constants.spinner.start).mockClear() - vi.mocked(constants.spinner.successAndStop).mockClear() - vi.mocked(constants.spinner.failAndStop).mockClear() - // @ts-ignore — spawn return type union; tests only use the three fields. - mocked.mockResolvedValue({ code: 0, stdout: 'ok', stderr: '' }) - }) - - it('builds the standard query argv shape', async () => { - await runBazelQuery('attr("tags", ".+", @maven//:*)', { - bin: '/usr/local/bin/bazel', - cwd: '/repo', - invocationFlags: [], - }) - const call = mocked.mock.calls[0]! - expect(call[0]).toBe('/usr/local/bin/bazel') - const argv = call[1] as string[] - expect(argv[0]).toBe('query') - expect(argv).toContain('--lockfile_mode=off') - expect(argv).toContain('--noshow_progress') - expect(argv).toContain('attr("tags", ".+", @maven//:*)') - expect(argv).toContain('--output=build') - }) - - it('forwards bazelRc as a startup flag BEFORE query', async () => { - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - bazelRc: '/path/to/.bazelrc', - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv[0]).toBe('--bazelrc=/path/to/.bazelrc') - expect(argv.indexOf('--bazelrc=/path/to/.bazelrc')).toBeLessThan( - argv.indexOf('query'), - ) - }) - - it('forwards outputUserRoot as a startup flag BEFORE the subcommand', async () => { - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - outputUserRoot: '/tmp/socket-bazel-xyz', - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('--output_user_root=/tmp/socket-bazel-xyz') - expect( - argv.indexOf('--output_user_root=/tmp/socket-bazel-xyz'), - ).toBeLessThan(argv.indexOf('query')) - }) - - it('forwards bazelOutputBase as a startup flag BEFORE query', async () => { - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - bazelOutputBase: '/tmp/output-base', - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('--output_base=/tmp/output-base') - expect(argv.indexOf('--output_base=/tmp/output-base')).toBeLessThan( - argv.indexOf('query'), - ) - }) - - it('appends invocationFlags after queryFlags', async () => { - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: ['--noenable_bzlmod', '--enable_workspace'], - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('--noenable_bzlmod') - expect(argv).toContain('--enable_workspace') - }) - - it('splits bazelFlags string on whitespace and appends', async () => { - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - bazelFlags: '--config=ci --keep_going', - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('--config=ci') - expect(argv).toContain('--keep_going') - }) - - it('forwards env to spawn when provided', async () => { - const env = { ...process.env, BAZEL_BENCH: 'yes' } - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - env, - }) - expect(mocked.mock.calls[0]![2]).toMatchObject({ cwd: '/r', env }) - }) - it('returns spawn result fields', async () => { - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValueOnce({ code: 0, stdout: 'OUT', stderr: 'ERR' }) - const r = await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - expect(r).toEqual({ code: 0, stdout: 'OUT', stderr: 'ERR' }) - }) - - it('stops spinner as failure when spawn resolves with non-zero code', async () => { - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValueOnce({ code: 7, stdout: '', stderr: 'boom' }) - const r = await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - expect(r).toEqual({ code: 7, stdout: '', stderr: 'boom' }) - expect(constants.spinner.successAndStop).not.toHaveBeenCalled() - expect(constants.spinner.failAndStop).toHaveBeenCalled() - }) - - it('normalizes rejected spawn errors with code, stdout, and stderr', async () => { - mocked.mockRejectedValueOnce( - Object.assign(new Error('bazel failed'), { - code: 42, - stderr: 'ERR', - stdout: 'OUT', - }), - ) - const r = await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - expect(r).toEqual({ code: 42, stdout: 'OUT', stderr: 'ERR' }) - expect(constants.spinner.failAndStop).toHaveBeenCalled() - }) - - it('preserves stderr from a rejected spawn so the caller sees the diagnostic', async () => { - mocked.mockRejectedValueOnce( - Object.assign(new Error('bazel resolution failed'), { - code: 1, - stderr: 'download failed: HTTP/2 503', - stdout: '', - }), - ) - const r = await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - expect(r).toEqual({ - code: 1, - stdout: '', - stderr: 'download failed: HTTP/2 503', - }) - }) - - it('normalizes rejected spawn errors without numeric code or status to -1', async () => { - mocked.mockRejectedValueOnce( - Object.assign(new Error('spawn failed'), { - code: 'ENOENT', - stderr: 'missing bazel', - stdout: '', - }), - ) - const r = await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - expect(r).toEqual({ code: -1, stdout: '', stderr: 'missing bazel' }) - }) - - it('emits bounded subprocess trace when verbose is true', async () => { - const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => logger) - try { - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValueOnce({ code: 7, stdout: 'OUT', stderr: 'ERR' }) - await runBazelQuery('q', { - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - verbose: true, - }) - const text = logSpy.mock.calls - .map(args => args.map(a => String(a)).join(' ')) - .join('\n') - expect(text).toContain('bazel subprocess trace') - expect(text).toContain('bazel stderr tail') - expect(text).toContain('bazel-query-failed') - } finally { - logSpy.mockRestore() +import type { BazelQueryOptions } from './bazel-query-runner.mts' + +describe('Bazel query arguments', () => { + const opts: BazelQueryOptions = { + bin: 'bazel', + cwd: '/repo', + invocationFlags: ['--noenable_bzlmod', '--enable_workspace'], + bazelRc: 'C:\\config files\\custom.bazelrc', + bazelOutputBase: 'C:\\output base', + outputUserRoot: 'C:\\short root', + bazelFlags: '--config=ci --define="path=C:\\Program Files\\data"', + } + const startup = [ + `--bazelrc=${opts.bazelRc}`, + `--output_user_root=${opts.outputUserRoot}`, + `--output_base=${opts.bazelOutputBase}`, + ] + + it('puts startup flags before every subcommand and keeps user flags intact', () => { + const { + buildBazelArgv, + buildBazelModShowMavenExtensionArgv, + buildBazelModShowPipExtensionArgv, + buildBazelModShowVisibleReposArgv, + buildBazelProbeCqueryArgv, + } = _internalArgvBuilders + const invocations = [ + buildBazelArgv('q', opts), + buildBazelProbeCqueryArgv('maven', opts), + buildMetadataCqueryArgv('maven', opts), + buildBazelModShowMavenExtensionArgv(opts), + buildBazelModShowPipExtensionArgv(opts), + buildBazelModShowVisibleReposArgv(opts), + ] + for (const argv of invocations) { + expect(argv.slice(0, startup.length)).toEqual(startup) + expect(argv.slice(-2)).toEqual([ + '--config=ci', + '--define=path=C:\\Program Files\\data', + ]) } }) -}) - -describe('runBazelModShowMavenExtension', () => { - const mocked = vi.mocked(spawn) - - beforeEach(() => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ - code: 0, - stdout: '## @@rules_jvm_external+//:extensions.bzl%maven:\n', - stderr: '', - }) - }) - it('uses the rules_jvm_external maven extension target', async () => { - await runBazelModShowMavenExtension({ - bin: 'bazel', - cwd: '/repo', - invocationFlags: [], - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toEqual([ - 'mod', - 'show_extension', - '@rules_jvm_external//:extensions.bzl%maven', + it('keeps query expressions and workspace flags as separate arguments', () => { + const expression = 'attr("tags", ".+", @maven//:*)' + expect(_internalArgvBuilders.buildBazelArgv(expression, opts)).toEqual([ + ...startup, + 'query', '--lockfile_mode=off', - '--extension_usages=', - ]) - }) - - it('pins the lockfile read-only so the scan never rewrites MODULE.bazel.lock', async () => { - await runBazelModShowMavenExtension({ - bin: 'bazel', - cwd: '/repo', - invocationFlags: [], - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('--lockfile_mode=off') - }) - - it('threads outputUserRoot ahead of the subcommand', async () => { - await runBazelModShowMavenExtension({ - bin: 'bazel', - cwd: '/repo', - invocationFlags: [], - outputUserRoot: '/tmp/socket-bazel-abc', - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toEqual([ - '--output_user_root=/tmp/socket-bazel-abc', - 'mod', - 'show_extension', - '@rules_jvm_external//:extensions.bzl%maven', - '--lockfile_mode=off', - '--extension_usages=', + '--noshow_progress', + ...opts.invocationFlags, + expression, + '--output=build', + '--config=ci', + '--define=path=C:\\Program Files\\data', ]) }) }) -describe('runBazelModShowVisibleRepos', () => { - const mocked = vi.mocked(spawn) +describe('Bazel query processes', () => { + let cwd: string + let bin: string + let opts: BazelQueryOptions beforeEach(() => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ code: 0, stdout: '{}', stderr: '' }) - }) - - it('uses the Bazel 7-compatible root repo mapping command', async () => { - await runBazelModShowVisibleRepos({ - bin: 'bazel', - cwd: '/repo', + cwd = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'bazel query '))) + bin = path.join(cwd, 'arguments.cjs') + writeFileSync( + bin, + 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), marker: process.env.BAZEL_QUERY_MARKER, cwd: process.cwd() }))\n', + ) + opts = { + bin, + cwd, invocationFlags: [], - }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toEqual(['mod', 'dump_repo_mapping', '', '--output=json']) - expect(argv).not.toContain('--all_visible_repos') - expect(argv).not.toContain('--output=streamed_jsonproto') + env: { BAZEL_QUERY_MARKER: 'supplied environment' }, + } }) -}) - -describe('runBazelModShowPipExtension', () => { - const mocked = vi.mocked(spawn) - beforeEach(() => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ code: 0, stdout: 'pip.parse()', stderr: '' }) + afterEach(() => { + rmSync(cwd, { recursive: true, force: true, maxRetries: 5 }) }) - it('uses the rules_python pip extension usage command', async () => { - await runBazelModShowPipExtension({ - bin: 'bazel', - cwd: '/repo', - invocationFlags: [], + it('runs a query in the supplied workspace and environment', async () => { + const result = await runBazelQuery('q', opts) + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout)).toEqual({ + args: [ + 'query', + '--lockfile_mode=off', + '--noshow_progress', + 'q', + '--output=build', + ], + marker: 'supplied environment', + cwd, }) - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toEqual([ - 'mod', - 'show_extension', - '@rules_python//python/extensions:pip.bzl%pip', - '--lockfile_mode=off', - '--extension_usages=', - ]) }) -}) - -describe('buildMavenProbeFor', () => { - const mocked = vi.mocked(spawn) - beforeEach(() => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ - code: 0, - stdout: '@maven//:foo\n@maven//:bar\n', - stderr: '', + it('runs the Maven extension command without updating lockfiles', async () => { + const result = await runBazelModShowMavenExtension(opts) + expect(JSON.parse(result.stdout)).toMatchObject({ + args: [ + 'mod', + 'show_extension', + '@rules_jvm_external//:extensions.bzl%maven', + '--lockfile_mode=off', + '--extension_usages=', + ], }) }) - it('builds the lightweight presence-check cquery for a repo name', async () => { - const probe = buildMavenProbeFor({ - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - const result = await probe('my_maven_repo') - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('cquery') - expect(argv).toContain('@my_maven_repo//...') - expect(argv).toContain('--output=label') - expect(argv).toContain('--keep_going') - expect(result).toEqual({ - code: 0, - stdout: '@maven//:foo\n@maven//:bar\n', - stderr: '', + it('runs the Python extension command without updating lockfiles', async () => { + const result = await runBazelModShowPipExtension(opts) + expect(JSON.parse(result.stdout)).toMatchObject({ + args: [ + 'mod', + 'show_extension', + '@rules_python//python/extensions:pip.bzl%pip', + '--lockfile_mode=off', + '--extension_usages=', + ], }) }) - it('threads outputUserRoot into the probe argv', async () => { - const probe = buildMavenProbeFor({ - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - outputUserRoot: '/tmp/x', + it('uses the root repository mapping command', async () => { + const result = await runBazelModShowVisibleRepos(opts) + expect(JSON.parse(result.stdout)).toMatchObject({ + args: ['mod', 'dump_repo_mapping', '', '--output=json'], }) - await probe('maven') - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv[0]).toBe('--output_user_root=/tmp/x') - expect(argv).toContain('@maven//...') }) - it('returns the full result triple including stderr (tri-state classifier needs it)', async () => { - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValueOnce({ - code: 1, - stdout: '', - stderr: "ERROR: No repository visible as '@nope' from main repository\n", - }) - const probe = buildMavenProbeFor({ - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - const result = await probe('nope') - expect(result).toEqual({ - code: 1, - stdout: '', - stderr: "ERROR: No repository visible as '@nope' from main repository\n", + it('probes Maven hubs with a lightweight cquery', async () => { + const result = await buildMavenProbeFor(opts)('maven') + expect(JSON.parse(result.stdout)).toMatchObject({ + args: [ + 'cquery', + '--lockfile_mode=off', + '--noshow_progress', + '@maven//...', + '--output=label', + '--keep_going', + ], }) }) -}) - -describe('buildPypiProbeFor', () => { - const mocked = vi.mocked(spawn) - beforeEach(() => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ - code: 0, - stdout: '@pypi//requests:pkg\n@pypi//flask:pkg\n', - stderr: '', + it('probes Python hubs with a hub-wide query', async () => { + const result = await buildPypiProbeFor(opts)('pypi') + expect(JSON.parse(result.stdout)).toMatchObject({ + args: [ + 'query', + '--lockfile_mode=off', + '--noshow_progress', + '@pypi//...', + '--output=build', + ], }) }) - it('builds a hub-wide query for a pip hub name', async () => { - const probe = buildPypiProbeFor({ - bin: 'bazel', - cwd: '/r', - invocationFlags: [], + it('preserves exit status and output from a failed process', async () => { + writeFileSync( + bin, + 'process.stdout.write("partial output")\nprocess.stderr.write("download failed: HTTP 503")\nprocess.exitCode = 42\n', + ) + expect(await runBazelQuery('q', opts)).toEqual({ + code: 42, + stdout: 'partial output', + stderr: 'download failed: HTTP 503', }) - const result = await probe('pypi') - const argv = mocked.mock.calls[0]![1] as string[] - expect(argv).toContain('@pypi//...') - expect(result).toEqual({ - code: 0, - stdout: expect.stringContaining('@pypi//requests:pkg'), - stderr: '', + expect(await buildMavenProbeFor(opts)('maven')).toEqual({ + code: 42, + stdout: 'partial output', + stderr: 'download failed: HTTP 503', }) }) - it('returns the full triple when the hub has no :pkg targets', async () => { - mocked.mockReset() - // @ts-ignore — narrow return shape for the test's purposes. - mocked.mockResolvedValue({ - code: 0, - stdout: '', - stderr: '', - }) - const probe = buildPypiProbeFor({ - bin: 'bazel', - cwd: '/r', - invocationFlags: [], - }) - const result = await probe('empty_hub') - expect(result).toEqual({ code: 0, stdout: '', stderr: '' }) + it('reports process startup failures', async () => { + expect( + await runBazelQuery('q', { ...opts, bin: path.join(cwd, 'missing') }), + ).toMatchObject({ code: -1, stdout: '' }) }) }) diff --git a/src/commands/manifest/bazel/cmd-manifest-bazel.mts b/src/commands/manifest/bazel/cmd-manifest-bazel.mts index 0ab10e861..9f6bd23ec 100644 --- a/src/commands/manifest/bazel/cmd-manifest-bazel.mts +++ b/src/commands/manifest/bazel/cmd-manifest-bazel.mts @@ -5,7 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { extractBazelToMaven } from './extract_bazel_to_maven.mts' import { extractBazelToPypi } from './extract_bazel_to_pypi.mts' -import constants, { SOCKET_JSON } from '../../../constants.mts' +import { DRY_RUN_BAILING_NOW, SOCKET_JSON } from '../../../constants.mts' import { commonFlags } from '../../../flags.mts' import { checkCommandInput } from '../../../utils/check-input.mts' import { InputError } from '../../../utils/errors.mts' @@ -35,7 +35,7 @@ const config: CliCommandConfig = { bazelFlags: { type: 'string', description: - 'Flags forwarded to every bazel invocation (single quoted string)', + 'Bazel command flags, with quoted values and literal backslashes', }, bazelOutputBase: { type: 'string', @@ -86,6 +86,20 @@ const config: CliCommandConfig = { Note: this command generates dependency manifests for Bazel workspaces. It does not run reachability analysis. + Native Windows support currently covers Maven. Use PowerShell or + Command Prompt with a working Python 3 python.exe and a JDK on PATH. + PyPI extraction on Windows is not supported yet. + + Set SOCKET_BAZEL_OUTPUT_ROOT to a short, writable parent directory, + such as C:/sb, to shorten connector-owned Bazel output paths. Each + invocation gets a separate directory. An explicit --bazel-output-base + takes precedence for Bazel's cache and stays under your control, + including server shutdown. + + In --bazel-flags, single or double quotes group values containing + spaces. Backslashes stay literal. Double a matching quote to include + it within a quoted value. Shell variables are not expanded. + Maven hub discovery: under Bzlmod, hubs are enumerated from \`bazel mod show_extension\` and filtered to the root module's own hubs. Under legacy WORKSPACE mode (no \`show_extension\`), only conventionally @@ -359,7 +373,7 @@ async function run( } if (dryRun) { - logger.log(constants.DRY_RUN_BAILING_NOW) + logger.log(DRY_RUN_BAILING_NOW) return } diff --git a/src/commands/manifest/bazel/extract_bazel_to_maven.mts b/src/commands/manifest/bazel/extract_bazel_to_maven.mts index 6248d1d21..bf663418d 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_maven.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_maven.mts @@ -1,20 +1,18 @@ -import { - existsSync, - promises as fs, - mkdirSync, - mkdtempSync, - readdirSync, -} from 'node:fs' -import os from 'node:os' +import { existsSync, promises as fs, mkdirSync, readdirSync } from 'node:fs' import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' -import { spawn } from '@socketsecurity/registry/lib/spawn' import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { + cleanUpBazelServer, + makeBazelOutputUserRoot, + removeBazelTempdir, +} from './bazel-cleanup.mts' import { runMetadataCqueryForRepo } from './bazel-cquery.mts' import { ensureJavaOnPath } from './bazel-java-shim.mts' import { validateOutputBase } from './bazel-output-base-check.mts' +import { getBazelEnvironment } from './bazel-process.mts' import { provisionPythonShim } from './bazel-python-shim.mts' import { buildMavenProbeFor, @@ -142,7 +140,6 @@ export type ExtractBazelResult = { } const DEFAULT_PER_REPO_TIMEOUT_MS = 60_000 -const REAP_TIMEOUT_MS = 10_000 // Machine-readable completeness signal emitted alongside the synthetic // manifests. A `complete: false` summary tells a downstream consumer (e.g. @@ -609,44 +606,18 @@ async function discoverCandidatesForWorkspace( return { candidates, discoveryIndeterminate, indeterminateProbes } } -// Best-effort reap of a Bazel server. Spawned with a short timeout so -// a wedged server can't itself hang the cleanup; failures are swallowed -// because the caller will `rm -rf` the output_user_root regardless. -async function reapBazelServer( - bin: string, - outputUserRoot: string, - verbose: boolean, -): Promise { - try { - await spawn(bin, [`--output_user_root=${outputUserRoot}`, 'shutdown'], { - timeout: REAP_TIMEOUT_MS, - }) - } catch (e) { - // Server may already be dead, or shutdown itself timed out — the - // tempdir removal below is sufficient cleanup. - if (verbose) { - logger.log( - `[VERBOSE] reapBazelServer: shutdown failed for ${outputUserRoot} (${getErrorCause(e)}); tempdir removal will still run`, - ) - } - } -} - -async function removeTempdir(dir: string, verbose: boolean): Promise { - try { - await fs.rm(dir, { recursive: true, force: true }) - } catch (e) { - // Best effort. The next CLI invocation lands a fresh tempdir. - if (verbose) { - logger.log( - `[VERBOSE] removeTempdir: ${dir} not fully removed (${getErrorCause(e)}); a stale dir may linger until the next OS tempdir sweep`, - ) +async function cleanUpOutputRoot( + root: string, + contexts: Map, +): Promise { + let stopped = true + for (const opts of contexts.values()) { + // eslint-disable-next-line no-await-in-loop + if (!(await cleanUpBazelServer(opts))) { + stopped = false } } -} - -function makeOutputUserRoot(): string { - return mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-')) + return stopped ? await removeBazelTempdir(root) : false } // Construct the BazelQueryOptions shape used for a single workspace's @@ -676,9 +647,11 @@ function buildQueryOpts(args: { cwd: spawnCwd, invocationFlags, outputUserRoot, - ...(opts.bazelRc ? { bazelRc: opts.bazelRc } : {}), + ...(opts.bazelRc ? { bazelRc: path.resolve(opts.cwd, opts.bazelRc) } : {}), ...(opts.bazelFlags ? { bazelFlags: opts.bazelFlags } : {}), - ...(opts.bazelOutputBase ? { bazelOutputBase: opts.bazelOutputBase } : {}), + ...(opts.bazelOutputBase + ? { bazelOutputBase: path.resolve(opts.cwd, opts.bazelOutputBase) } + : {}), ...(baseEnv ? { env: baseEnv } : {}), verbose, } @@ -700,16 +673,23 @@ export async function extractBazelToMaven( // Validate config + ensure toolchains BEFORE we mint a tempdir. let bin: string - let baseEnv: NodeJS.ProcessEnv | undefined + let baseEnv = getBazelEnvironment(opts.env) + let pythonShimDir: string | undefined + let outputUserRoot: string try { if (opts.bazelOutputBase) { validateOutputBase(opts.bazelOutputBase, opts.cwd) } - ensureJavaOnPath() - const shim = await provisionPythonShim() - baseEnv = shim.augmentedEnv ?? opts.env - bin = await resolveBazelBinary(opts.bin) + ensureJavaOnPath(baseEnv, cwd) + bin = await resolveBazelBinary(opts.bin, baseEnv, cwd) + const shim = await provisionPythonShim(baseEnv, cwd) + pythonShimDir = shim.shimDir + baseEnv = shim.augmentedEnv ?? baseEnv + outputUserRoot = makeBazelOutputUserRoot(baseEnv, cwd) } catch (e) { + if (pythonShimDir) { + await removeBazelTempdir(pythonShimDir) + } logger.fail(`Unexpected error in bazel2maven: ${getErrorCause(e)}`) if (verbose) { logger.group('[VERBOSE] error:') @@ -728,8 +708,9 @@ export async function extractBazelToMaven( // Track every output_user_root we mint so we can reap them all in // the cleanup pass, even if a per-repo timeout forced a re-mint. - let outputUserRoot = makeOutputUserRoot() - const mintedRoots: string[] = [outputUserRoot] + const mintedRoots = new Map>([ + [outputUserRoot, new Map()], + ]) if (verbose) { logger.log( `[VERBOSE] initial --output_user_root=${outputUserRoot} (will be reaped on completion)`, @@ -762,6 +743,7 @@ export async function extractBazelToMaven( // callers EXTEND it via ignoreDirNames/ignoreDirPrefixes. const ignoreDirNames = new Set([ ...DEFAULT_BAZEL_WALKER_IGNORE_DIR_NAMES, + path.basename(outputUserRoot), ...(opts.ignoreDirNames ?? []), ]) const ignoreDirPrefixes = [ @@ -826,8 +808,8 @@ export async function extractBazelToMaven( `Workspace ${relPath || '.'}: bzlmod=${mode.bzlmod} workspace=${mode.workspace}`, ) const invocationFlags = getBazelInvocationFlags(mode) - const queryOptsFor = (userRoot: string): BazelQueryOptions => - buildQueryOpts({ + const queryOptsFor = (userRoot: string): BazelQueryOptions => { + const queryOpts = buildQueryOpts({ baseEnv, bin, invocationFlags, @@ -836,6 +818,9 @@ export async function extractBazelToMaven( spawnCwd: workspaceRoot, verbose, }) + mintedRoots.get(userRoot)?.set(workspaceRoot, queryOpts) + return queryOpts + } const { candidates, discoveryIndeterminate, indeterminateProbes } = // eslint-disable-next-line no-await-in-loop @@ -930,11 +915,15 @@ export async function extractBazelToMaven( state: 'failed', }) // eslint-disable-next-line no-await-in-loop - await reapBazelServer(bin, outputUserRoot, verbose) - // eslint-disable-next-line no-await-in-loop - await removeTempdir(outputUserRoot, verbose) - outputUserRoot = makeOutputUserRoot() - mintedRoots.push(outputUserRoot) + const cleaned = await cleanUpOutputRoot( + outputUserRoot, + mintedRoots.get(outputUserRoot) ?? new Map(), + ) + if (cleaned) { + mintedRoots.delete(outputUserRoot) + } + outputUserRoot = makeBazelOutputUserRoot(baseEnv, cwd) + mintedRoots.set(outputUserRoot, new Map()) if (verbose) { logger.log( `[VERBOSE] minted fresh --output_user_root=${outputUserRoot} after timeout`, @@ -1203,11 +1192,15 @@ export async function extractBazelToMaven( workspaceOutcomes, } } finally { - for (const dir of mintedRoots) { + let cleaned = true + for (const [root, contexts] of mintedRoots) { // eslint-disable-next-line no-await-in-loop - await reapBazelServer(bin, dir, verbose) - // eslint-disable-next-line no-await-in-loop - await removeTempdir(dir, verbose) + if (!(await cleanUpOutputRoot(root, contexts))) { + cleaned = false + } + } + if (pythonShimDir && cleaned) { + await removeBazelTempdir(pythonShimDir) } } } diff --git a/src/commands/manifest/bazel/extract_bazel_to_pypi.constructed.test.mts b/src/commands/manifest/bazel/extract_bazel_to_pypi.constructed.test.mts index 7687ae121..5624b6d13 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_pypi.constructed.test.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_pypi.constructed.test.mts @@ -6,40 +6,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { extractBazelToPypi } from './extract_bazel_to_pypi.mts' -const FIXTURE_DIR = path.resolve( - import.meta.dirname, - '..', - '..', - '..', - '..', - '..', - 'bazel-bench', - 'constructed', - 'python-pypi', -) - -function isSandboxed(): boolean { - // Detect sandbox by probing a Bazel server socket bind or a write to - // /var/tmp/_bazel_$USER (both blocked in the agent sandbox). - try { - // A quick heuristic: if /var/tmp/_bazel_$USER is not writable and we're - // on macOS, the sandbox is likely active. - const { accessSync, constants } = require('node:fs') - accessSync( - `/var/tmp/_bazel_${process.env['USER'] ?? 'unknown'}`, - constants.W_OK, - ) - return false - } catch { - return true - } -} +const FIXTURE_DIR = process.env['SOCKET_BAZEL_PYPI_FIXTURE'] ?? '' function normalizeFinalNewline(text: string): string { return text.replace(/\r\n/g, '\n').replace(/\n?$/, '\n') } -describe.skipIf(isSandboxed())( +describe.skipIf(!FIXTURE_DIR)( 'extract_bazel_to_pypi — constructed fixture', () => { let tmp: string diff --git a/src/commands/manifest/bazel/extract_bazel_to_pypi.mts b/src/commands/manifest/bazel/extract_bazel_to_pypi.mts index a9c81f0af..8a774e69b 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_pypi.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_pypi.mts @@ -4,7 +4,9 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { removeBazelTempdir } from './bazel-cleanup.mts' import { validateOutputBase } from './bazel-output-base-check.mts' +import { getBazelEnvironment } from './bazel-process.mts' import { discoverPypiHubs, parseBazelModPipExtensionCandidates, @@ -30,7 +32,7 @@ import { detectWorkspaceMode, getBazelInvocationFlags, } from './bazel-workspace-detect.mts' -import { getErrorCause } from '../../../utils/errors.mts' +import { InputError, getErrorCause } from '../../../utils/errors.mts' import type { PypiHubCandidate } from './bazel-pypi-discovery.mts' import type { @@ -88,14 +90,22 @@ export async function extractBazelToPypi( } logger.groupEnd() + let pythonShimDir: string | undefined try { + if (process.platform === 'win32') { + throw new InputError( + 'Bazel PyPI extraction on Windows is not supported yet. Platform-specific dependency selection still needs verification. Use --ecosystem maven for Windows SCA scans.', + ) + } // Validate caller-provided Bazel filesystem settings before invoking Bazel. if (opts.bazelOutputBase) { validateOutputBase(opts.bazelOutputBase, opts.cwd) } // Python shim (for rules_python workspace discovery). - const shim = await provisionPythonShim() - const baseEnv = shim.augmentedEnv ?? opts.env + const env = getBazelEnvironment(opts.env) + const shim = await provisionPythonShim(env, cwd) + pythonShimDir = shim.shimDir + const baseEnv = shim.augmentedEnv ?? env // Step 1: workspace detection. const mode = detectWorkspaceMode(cwd) @@ -105,7 +115,7 @@ export async function extractBazelToPypi( const invocationFlags = getBazelInvocationFlags(mode) // Step 2: bazel binary resolution. - const bin = await resolveBazelBinary(opts.bin) + const bin = await resolveBazelBinary(opts.bin, baseEnv, cwd) logger.info(`Using bazel: ${bin}`) if (verbose) { logger.log('[VERBOSE] resolved options:', { @@ -299,6 +309,10 @@ export async function extractBazelToPypi( logger.info('Re-run with --verbose for the full stack.') } return { artifactCount: 0, ok: false } + } finally { + if (pythonShimDir) { + await removeBazelTempdir(pythonShimDir) + } } } diff --git a/test/fixtures/manifest-bazel/native/bzlmod/.bazelversion b/test/fixtures/manifest-bazel/native/bzlmod/.bazelversion new file mode 100644 index 000000000..815da58b7 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/bzlmod/.bazelversion @@ -0,0 +1 @@ +7.4.1 diff --git a/test/fixtures/manifest-bazel/native/bzlmod/BUILD.bazel b/test/fixtures/manifest-bazel/native/bzlmod/BUILD.bazel new file mode 100644 index 000000000..e0911ad69 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/bzlmod/BUILD.bazel @@ -0,0 +1 @@ +filegroup(name = "fixture") diff --git a/test/fixtures/manifest-bazel/native/bzlmod/MODULE.bazel b/test/fixtures/manifest-bazel/native/bzlmod/MODULE.bazel new file mode 100644 index 000000000..9dcb4acad --- /dev/null +++ b/test/fixtures/manifest-bazel/native/bzlmod/MODULE.bazel @@ -0,0 +1,11 @@ +module(name = "socket_windows_maven_fixture", version = "0.0.0") + +bazel_dep(name = "rules_jvm_external", version = "6.6") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + name = "socket_fixture", + artifacts = ["junit:junit:4.13.2"], + repositories = ["https://repo.maven.apache.org/maven2"], +) +use_repo(maven, "socket_fixture") diff --git a/test/fixtures/manifest-bazel/native/maven.expected.json b/test/fixtures/manifest-bazel/native/maven.expected.json new file mode 100644 index 000000000..784af6457 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/maven.expected.json @@ -0,0 +1,9 @@ +{ + "artifacts": { + "junit:junit": { "version": "4.13.2" }, + "org.hamcrest:hamcrest-core": { "version": "1.3" } + }, + "dependencies": { + "junit:junit": ["org.hamcrest:hamcrest-core"] + } +} diff --git a/test/fixtures/manifest-bazel/native/workspace/.bazelversion b/test/fixtures/manifest-bazel/native/workspace/.bazelversion new file mode 100644 index 000000000..815da58b7 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/workspace/.bazelversion @@ -0,0 +1 @@ +7.4.1 diff --git a/test/fixtures/manifest-bazel/native/workspace/BUILD.bazel b/test/fixtures/manifest-bazel/native/workspace/BUILD.bazel new file mode 100644 index 000000000..e0911ad69 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/workspace/BUILD.bazel @@ -0,0 +1 @@ +filegroup(name = "fixture") diff --git a/test/fixtures/manifest-bazel/native/workspace/WORKSPACE b/test/fixtures/manifest-bazel/native/workspace/WORKSPACE new file mode 100644 index 000000000..67809b8c4 --- /dev/null +++ b/test/fixtures/manifest-bazel/native/workspace/WORKSPACE @@ -0,0 +1,37 @@ +workspace(name = "socket_windows_maven_fixture") + +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "rules_cc", + sha256 = "ca772d4fa149180dd1d81fe19a61c911dcebf9768d56209fc5bf382125ade0b6", + strip_prefix = "rules_cc-faeafdb82814b4f7295c555781e800f080607bdd", + urls = ["https://github.com/bazelbuild/rules_cc/archive/faeafdb82814b4f7295c555781e800f080607bdd.tar.gz"], +) + +http_archive( + name = "protobuf", + sha256 = "da288bf1daa6c04d03a9051781caa52aceb9163586bff9aa6cfb12f69b9395aa", + strip_prefix = "protobuf-27.0", + urls = ["https://github.com/protocolbuffers/protobuf/releases/download/v27.0/protobuf-27.0.tar.gz"], +) + +http_archive( + name = "rules_jvm_external", + sha256 = "3afe5195069bd379373528899c03a3072f568d33bd96fe037bd43b1f590535e7", + strip_prefix = "rules_jvm_external-6.6", + urls = ["https://github.com/bazel-contrib/rules_jvm_external/releases/download/6.6/rules_jvm_external-6.6.tar.gz"], +) + +load("@rules_jvm_external//:repositories.bzl", "rules_jvm_external_deps") +rules_jvm_external_deps() + +load("@rules_jvm_external//:setup.bzl", "rules_jvm_external_setup") +rules_jvm_external_setup() + +load("@rules_jvm_external//:defs.bzl", "maven_install") +maven_install( + name = "maven", + artifacts = ["junit:junit:4.13.2"], + repositories = ["https://repo.maven.apache.org/maven2"], +) From a63a4bae19b35cc7d43f313a5cb2c07f32450f3c Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 26 Oct 2026 15:54:03 +0100 Subject: [PATCH 2/8] fix: keep native Windows executable paths Use executable lookup before unwrapping Node launchers so native Bazel and Python executables remain runnable. Keep custom batch wrappers intact and invoke their selected paths. Pin the existing which version without changing resolutions. Assisted-by: Codex:GPT-6 --- package.json | 1 + pnpm-lock.yaml | 3 ++ .../manifest/bazel/bazel-bin-detect.mts | 10 ++-- src/commands/manifest/bazel/bazel-process.mts | 47 +++++++++++++++---- .../manifest/bazel/bazel-process.test.mts | 7 ++- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 7faa2e924..82b90fda2 100644 --- a/package.json +++ b/package.json @@ -198,6 +198,7 @@ "typescript-eslint": "8.43.0", "unplugin-purge-polyfills": "0.1.0", "vitest": "3.2.4", + "which": "=5.0.0", "yaml": "2.8.1", "yargs-parser": "21.1.1", "yoctocolors-cjs": "2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7715724c..354dec21c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,9 @@ importers: vitest: specifier: 3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.1)(jiti@2.6.1)(yaml@2.8.1) + which: + specifier: =5.0.0 + version: 5.0.0 yaml: specifier: 2.8.1 version: 2.8.1 diff --git a/src/commands/manifest/bazel/bazel-bin-detect.mts b/src/commands/manifest/bazel/bazel-bin-detect.mts index 4a63279a1..d6d0757f8 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.mts @@ -1,9 +1,11 @@ import { existsSync } from 'node:fs' import path from 'node:path' -import { resolveBinPathSync } from '@socketsecurity/registry/lib/bin' - -import { findBazelExecutable, getBazelEnvironment } from './bazel-process.mts' +import { + findBazelExecutable, + getBazelEnvironment, + resolveBazelExecutable, +} from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' /** @@ -27,7 +29,7 @@ export async function resolveBazelBinary( `--bazel path does not exist: ${explicit}. Install bazelisk or bazel, or pass an existing path via --bazel.`, ) } - return resolveBinPathSync(resolved) + return resolveBazelExecutable(resolved) } // Prefer bazelisk: respects .bazelversion in the workspace. const bazelisk = await findBazelExecutable('bazelisk', env, cwd) diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts index b3fa2c38b..4c579d3c9 100644 --- a/src/commands/manifest/bazel/bazel-process.mts +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -1,7 +1,15 @@ -import { closeSync, existsSync, openSync, readSync } from 'node:fs' +import { + closeSync, + existsSync, + openSync, + readSync, + realpathSync, +} from 'node:fs' import path from 'node:path' -import { whichBin } from '@socketsecurity/registry/lib/bin' +import which from 'which' + +import { resolveBinPathSync } from '@socketsecurity/registry/lib/bin' import { spawn } from '@socketsecurity/registry/lib/spawn' import { InputError } from '../../../utils/errors.mts' @@ -45,16 +53,35 @@ export async function findBazelExecutable( .split(path.delimiter) .map(entry => path.resolve(cwd, entry)) .join(path.delimiter) + const found = await which(name, { + nothrow: true, + path: searchPath, + pathExt: getBazelEnvironmentValue(env, 'PATHEXT'), + }) + return found ? resolveBazelExecutable(found) : undefined +} + +export function resolveBazelExecutable(file: string): string { + const resolved = realpathSync(file) + const extension = path.extname(resolved).toLowerCase() + if ( + process.platform !== 'win32' || + !['', '.cmd', '.ps1'].includes(extension) + ) { + return resolved + } try { - return await whichBin(name, { - path: searchPath, - pathExt: getBazelEnvironmentValue(env, 'PATHEXT'), - }) - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return undefined + return resolveBinPathSync(resolved) + } catch (e) { + if (extension === '.ps1') { + throw new InputError( + 'This PowerShell Bazel launcher could not be resolved to a Node entry point. Pass a native .exe, a .cmd or .bat wrapper, or a JavaScript entry point with --bazel.', + ) + } + if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { + return resolved } - throw error + throw e } } diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index d049255c0..f2812a25b 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -107,7 +107,12 @@ describe('Bazel process invocation', () => { wrapper, `@echo off\r\n"${process.execPath}" "${launcher}" %*\r\n`, ) - const result = await spawnBazel(wrapper, ARGUMENTS, { + const bin = await resolveBazelBinary( + wrapper, + getBazelEnvironment({ PATH: cwd }), + cwd, + ) + const result = await spawnBazel(bin, ARGUMENTS, { cwd, env: { SOCKET_BAZEL_LITERAL: 'must not expand' }, }) From 0c7235dd860dddb70044a3dffa1aa00a9d54127b Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 26 Oct 2026 15:58:05 +0100 Subject: [PATCH 3/8] test: handle Windows executable path casing Compare executable identity so case-insensitive Windows paths do not fail when PATHEXT uses an uppercase extension. Assisted-by: Codex:GPT-6 --- .../manifest/bazel/bazel-bin-detect.test.mts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/manifest/bazel/bazel-bin-detect.test.mts b/src/commands/manifest/bazel/bazel-bin-detect.test.mts index 2f033621e..833d86e84 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.test.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.test.mts @@ -1,4 +1,10 @@ -import { copyFileSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { + copyFileSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -34,7 +40,7 @@ describe('resolveBazelBinary', () => { undefined, cwd, ) - expect(path.normalize(resolved)).toBe(bin) + expect(statSync(resolved).ino).toBe(statSync(bin).ino) }) it('preserves an explicit executable outside PATH', async () => { @@ -44,7 +50,7 @@ describe('resolveBazelBinary', () => { getBazelEnvironment({ PATH: '' }), cwd, ) - expect(path.normalize(resolved)).toBe(bin) + expect(statSync(resolved).ino).toBe(statSync(bin).ino) }) it('prefers Bazelisk over Bazel', async () => { @@ -55,7 +61,7 @@ describe('resolveBazelBinary', () => { getBazelEnvironment({ PATH: cwd }), cwd, ) - expect(path.normalize(resolved)).toBe(bazelisk) + expect(statSync(resolved).ino).toBe(statSync(bazelisk).ino) }) it('falls back to Bazel when Bazelisk is absent', async () => { @@ -65,7 +71,7 @@ describe('resolveBazelBinary', () => { getBazelEnvironment({ PATH: cwd }), cwd, ) - expect(path.normalize(resolved)).toBe(bazel) + expect(statSync(resolved).ino).toBe(statSync(bazel).ino) }) it('reports missing tools', async () => { From d4378dc46429f25dddea1cebbdb41b1109374b9d Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Mon, 26 Oct 2026 17:09:36 +0100 Subject: [PATCH 4/8] fix: only stop Bazel servers this scan started Ask Bazel for the effective output base before shutdown, so an rc file that selects a caller-owned output base keeps its server alive. Skip every socket-bazel- output directory during workspace discovery, and strip quotes from PATH entries before resolving them. Simplify the Windows process helpers. Use one environment key lookup, one batch escaper and one Python shim result shape, require explicit env and cwd, shut down servers in parallel, and cancel superseded Windows CI runs. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/bazel-windows.yml | 11 +-- .../manifest/bazel/bazel-bin-detect.mts | 5 +- .../manifest/bazel/bazel-bin-detect.test.mts | 18 +++- src/commands/manifest/bazel/bazel-cleanup.mts | 72 ++++++++++------ .../manifest/bazel/bazel-java-shim.mts | 10 +-- .../manifest/bazel/bazel-native.e2e.test.mts | 12 ++- src/commands/manifest/bazel/bazel-process.mts | 72 ++++++++++------ .../manifest/bazel/bazel-process.test.mts | 82 +++++++++++++++---- .../manifest/bazel/bazel-python-shim.mts | 22 ++--- .../manifest/bazel/bazel-python-shim.test.mts | 20 +++-- .../manifest/bazel/extract_bazel_to_maven.mts | 35 ++++---- .../bazel/extract_bazel_to_maven.test.mts | 5 +- .../manifest/bazel/extract_bazel_to_pypi.mts | 4 +- .../bazel/extract_bazel_to_pypi.test.mts | 4 +- 14 files changed, 227 insertions(+), 145 deletions(-) diff --git a/.github/workflows/bazel-windows.yml b/.github/workflows/bazel-windows.yml index 89531338a..b2ca78833 100644 --- a/.github/workflows/bazel-windows.yml +++ b/.github/workflows/bazel-windows.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: bazel-windows-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: maven: name: Maven on Windows x64 @@ -25,7 +29,6 @@ jobs: SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true git init -q git config --local advice.detachedHead false @@ -39,7 +42,6 @@ jobs: - name: Install pinned toolchains run: | - $ErrorActionPreference = 'Stop' $tools = Join-Path $env:RUNNER_TEMP 'bazel-tools' New-Item -ItemType Directory -Force "$tools/bin" | Out-Null function Get-VerifiedTool($url, $file, $sha256) { @@ -67,7 +69,6 @@ jobs: - name: Record versions and verify prerequisites run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true git rev-parse HEAD node --version @@ -81,20 +82,17 @@ jobs: - name: Install dependencies and build run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true pnpm install --frozen-lockfile pnpm build - name: Test executable handling and prerequisites run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true pnpm test:unit src/commands/manifest/bazel/bazel-bin-detect.test.mts src/commands/manifest/bazel/bazel-process.test.mts src/commands/manifest/bazel/bazel-java-shim.test.mts src/commands/manifest/bazel/bazel-python-shim.test.mts src/commands/manifest/bazel/bazel-query-runner.test.mts src/commands/manifest/bazel/bazel-cquery.test.mts - name: Install the packaged CLI run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true $packages = Join-Path $env:RUNNER_TEMP 'bazel-packages' $installation = Join-Path $env:RUNNER_TEMP 'bazel-installation' @@ -107,6 +105,5 @@ jobs: env: SOCKET_BAZEL_INTEGRATION: '1' run: | - $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true pnpm exec vitest run --config vitest.e2e.config.mts src/commands/manifest/bazel/bazel-native.e2e.test.mts diff --git a/src/commands/manifest/bazel/bazel-bin-detect.mts b/src/commands/manifest/bazel/bazel-bin-detect.mts index d6d0757f8..24f325e32 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.mts @@ -3,7 +3,6 @@ import path from 'node:path' import { findBazelExecutable, - getBazelEnvironment, resolveBazelExecutable, } from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' @@ -19,8 +18,8 @@ import { InputError } from '../../../utils/errors.mts' */ export async function resolveBazelBinary( explicit: string | undefined, - env: NodeJS.ProcessEnv = getBazelEnvironment(), - cwd: string = process.cwd(), + env: NodeJS.ProcessEnv, + cwd: string, ): Promise { if (explicit) { const resolved = path.resolve(cwd, explicit) diff --git a/src/commands/manifest/bazel/bazel-bin-detect.test.mts b/src/commands/manifest/bazel/bazel-bin-detect.test.mts index 833d86e84..800adf96f 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.test.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.test.mts @@ -37,7 +37,7 @@ describe('resolveBazelBinary', () => { const bin = install('bazel') const resolved = await resolveBazelBinary( path.basename(bin), - undefined, + getBazelEnvironment(), cwd, ) expect(statSync(resolved).ino).toBe(statSync(bin).ino) @@ -74,12 +74,22 @@ describe('resolveBazelBinary', () => { expect(statSync(resolved).ino).toBe(statSync(bazel).ino) }) + it('finds tools in a quoted PATH entry', async () => { + const bazel = install('bazel') + const resolved = await resolveBazelBinary( + undefined, + getBazelEnvironment({ PATH: `"${cwd}"` }), + os.tmpdir(), + ) + expect(statSync(resolved).ino).toBe(statSync(bazel).ino) + }) + it('reports missing tools', async () => { await expect( resolveBazelBinary(undefined, getBazelEnvironment({ PATH: cwd }), cwd), ).rejects.toThrow(/Could not find bazelisk or bazel/) - await expect(resolveBazelBinary(path.join(cwd, 'missing'))).rejects.toThrow( - /--bazel path does not exist/, - ) + await expect( + resolveBazelBinary(path.join(cwd, 'missing'), getBazelEnvironment(), cwd), + ).rejects.toThrow(/--bazel path does not exist/) }) }) diff --git a/src/commands/manifest/bazel/bazel-cleanup.mts b/src/commands/manifest/bazel/bazel-cleanup.mts index 30acbb01a..e29505c2d 100644 --- a/src/commands/manifest/bazel/bazel-cleanup.mts +++ b/src/commands/manifest/bazel/bazel-cleanup.mts @@ -1,4 +1,4 @@ -import { promises as fs, mkdirSync, mkdtempSync } from 'node:fs' +import { promises as fs, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -9,10 +9,12 @@ import { getBazelEnvironmentValue, spawnBazel, } from './bazel-process.mts' -import { getErrorCause } from '../../../utils/errors.mts' +import { getErrorCause, isErrnoException } from '../../../utils/errors.mts' import type { BazelQueryOptions } from './bazel-query-runner.mts' +export const BAZEL_OUTPUT_ROOT_PREFIX = 'socket-bazel-' + export function makeBazelOutputUserRoot( env: NodeJS.ProcessEnv, cwd: string, @@ -20,33 +22,26 @@ export function makeBazelOutputUserRoot( const parent = getBazelEnvironmentValue(env, 'SOCKET_BAZEL_OUTPUT_ROOT') const resolved = parent ? path.resolve(cwd, parent) : os.tmpdir() mkdirSync(resolved, { recursive: true }) - return mkdtempSync(path.join(resolved, 'socket-bazel-')) + return mkdtempSync(path.join(resolved, BAZEL_OUTPUT_ROOT_PREFIX)) +} + +const RM_OPTIONS = { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, } export async function removeBazelTempdir(dir: string): Promise { try { try { - await fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 200, - }) + await fs.rm(dir, RM_OPTIONS) } catch (e) { - if ( - !(e instanceof Error) || - !('code' in e) || - !['EACCES', 'EPERM'].includes(String(e.code)) - ) { + if (!isErrnoException(e) || !['EACCES', 'EPERM'].includes(e.code ?? '')) { throw e } await makeDirectoriesWritable(dir) - await fs.rm(dir, { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 200, - }) + await fs.rm(dir, RM_OPTIONS) } return true } catch (error) { @@ -81,7 +76,7 @@ async function makeDirectoriesWritable(root: string): Promise { } } } catch (e) { - if (!(e instanceof Error) || !('code' in e) || e.code !== 'ENOENT') { + if (!isErrnoException(e) || e.code !== 'ENOENT') { throw e } } @@ -94,13 +89,24 @@ export async function cleanUpBazelServer( if (!opts.outputUserRoot || opts.bazelOutputBase) { return true } + const startupFlags = buildBazelStartupFlags(opts) + const spawnOptions = { + cwd: opts.cwd, + ...(opts.env ? { env: opts.env } : {}), + signal: new AbortController().signal, + timeout: 10_000, + } try { - await spawnBazel(opts.bin, [...buildBazelStartupFlags(opts), 'shutdown'], { - cwd: opts.cwd, - ...(opts.env ? { env: opts.env } : {}), - signal: new AbortController().signal, - timeout: 10_000, - }) + // An rc file can set `startup --output_base`, which points the scan at a server it does not own. + const info = await spawnBazel( + opts.bin, + [...startupFlags, 'info', 'output_base'], + spawnOptions, + ) + if (!isPathInside(info.stdout.trim(), opts.outputUserRoot)) { + return true + } + await spawnBazel(opts.bin, [...startupFlags, 'shutdown'], spawnOptions) return true } catch (error) { logger.warn( @@ -109,3 +115,15 @@ export async function cleanUpBazelServer( return false } } + +function isPathInside(child: string, parent: string): boolean { + const relative = path.relative( + realpathSync.native(parent), + realpathSync.native(child), + ) + return ( + relative !== '' && + relative.split(path.sep)[0] !== '..' && + !path.isAbsolute(relative) + ) +} diff --git a/src/commands/manifest/bazel/bazel-java-shim.mts b/src/commands/manifest/bazel/bazel-java-shim.mts index 29e468937..8c61c2c7d 100644 --- a/src/commands/manifest/bazel/bazel-java-shim.mts +++ b/src/commands/manifest/bazel/bazel-java-shim.mts @@ -1,16 +1,10 @@ import { execFileSync } from 'node:child_process' import path from 'node:path' -import { - getBazelEnvironment, - getBazelEnvironmentValue, -} from './bazel-process.mts' +import { getBazelEnvironmentValue } from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' -export function ensureJavaOnPath( - env: NodeJS.ProcessEnv = getBazelEnvironment(), - cwd: string = process.cwd(), -): void { +export function ensureJavaOnPath(env: NodeJS.ProcessEnv, cwd: string): void { const javaHome = getBazelEnvironmentValue(env, 'JAVA_HOME') const executable = process.platform === 'win32' ? 'java.exe' : 'java' const java = javaHome diff --git a/src/commands/manifest/bazel/bazel-native.e2e.test.mts b/src/commands/manifest/bazel/bazel-native.e2e.test.mts index 78d799bdd..0872a0262 100644 --- a/src/commands/manifest/bazel/bazel-native.e2e.test.mts +++ b/src/commands/manifest/bazel/bazel-native.e2e.test.mts @@ -40,9 +40,15 @@ describe.skipIf(process.env['SOCKET_BAZEL_INTEGRATION'] !== '1')( let bin: string beforeAll(async () => { - bin = await resolveBazelBinary(process.env['SOCKET_BAZEL_TEST_BIN']) - ensureJavaOnPath() - const python = await provisionPythonShim() + const env = getBazelEnvironment() + const cwd = process.cwd() + bin = await resolveBazelBinary( + process.env['SOCKET_BAZEL_TEST_BIN'], + env, + cwd, + ) + ensureJavaOnPath(env, cwd) + const python = await provisionPythonShim(env, cwd) if (python.shimDir) { rmSync(python.shimDir, { recursive: true, force: true }) } diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts index 4c579d3c9..c344691bf 100644 --- a/src/commands/manifest/bazel/bazel-process.mts +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -12,23 +12,29 @@ import which from 'which' import { resolveBinPathSync } from '@socketsecurity/registry/lib/bin' import { spawn } from '@socketsecurity/registry/lib/spawn' -import { InputError } from '../../../utils/errors.mts' +import { InputError, isErrnoException } from '../../../utils/errors.mts' import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' +function findEnvironmentKey( + env: NodeJS.ProcessEnv, + name: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform !== 'win32') { + return name + } + const lower = name.toLowerCase() + return Object.keys(env).find(key => key.toLowerCase() === lower) ?? name +} + export function getBazelEnvironment( overrides: NodeJS.ProcessEnv = {}, platform: NodeJS.Platform = process.platform, ): NodeJS.ProcessEnv { const env = { ...process.env } for (const [key, value] of Object.entries(overrides)) { - const existing = - platform === 'win32' - ? Object.keys(env).find( - name => name.toLowerCase() === key.toLowerCase(), - ) - : undefined - env[existing ?? key] = value + env[findEnvironmentKey(env, key, platform)] = value } return env } @@ -37,11 +43,15 @@ export function getBazelEnvironmentValue( env: NodeJS.ProcessEnv, name: string, ): string | undefined { - const key = - process.platform === 'win32' - ? Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()) - : name - return key ? env[key] : undefined + return env[findEnvironmentKey(env, name)] +} + +export function prependBazelPath( + env: NodeJS.ProcessEnv, + dir: string, +): NodeJS.ProcessEnv { + const key = findEnvironmentKey(env, 'PATH') + return { ...env, [key]: `${dir}${path.delimiter}${env[key] ?? ''}` } } export async function findBazelExecutable( @@ -51,7 +61,7 @@ export async function findBazelExecutable( ): Promise { const searchPath = (getBazelEnvironmentValue(env, 'PATH') ?? '') .split(path.delimiter) - .map(entry => path.resolve(cwd, entry)) + .map(entry => path.resolve(cwd, entry.replace(/^"(.*)"$/, '$1'))) .join(path.delimiter) const found = await which(name, { nothrow: true, @@ -78,7 +88,7 @@ export function resolveBazelExecutable(file: string): string { 'This PowerShell Bazel launcher could not be resolved to a Node entry point. Pass a native .exe, a .cmd or .bat wrapper, or a JavaScript entry point with --bazel.', ) } - if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { + if (isErrnoException(e) && e.code === 'ENOENT') { return resolved } throw e @@ -102,6 +112,12 @@ function isNodeScript(bin: string): boolean { } } +const BATCH_METACHARACTERS = /[ !%^&()<>|"]/g + +function escapeBatchMetacharacters(value: string): string { + return value.replace(BATCH_METACHARACTERS, '^$&') +} + function escapeBatchArgument(value: string): string { if (/[\r\n\0]/.test(value)) { throw new InputError( @@ -110,29 +126,33 @@ function escapeBatchArgument(value: string): string { } const quoted = `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"` // Batch forwarding parses arguments twice, so protect each shell metacharacter twice. - return quoted - .replace(/[ !%^&()<>|"]/g, '^$&') - .replace(/[ !%^&()<>|"]/g, '^$&') + return escapeBatchMetacharacters(escapeBatchMetacharacters(quoted)) } -export function buildBazelProcessInvocation( +function buildBazelProcessInvocation( bin: string, args: string[], env: NodeJS.ProcessEnv, -): { command: string; args: string[]; windowsVerbatimArguments?: boolean } { +): { command: string; args: string[]; windowsVerbatimArguments: boolean } { if (isNodeScript(bin)) { - return { command: process.execPath, args: [bin, ...args] } + return { + command: process.execPath, + args: [bin, ...args], + windowsVerbatimArguments: false, + } } if (process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(bin)) { - const command = bin.replace(/[ !%^&()<>|"]/g, '^$&') - const script = [command, ...args.map(escapeBatchArgument)].join(' ') + const script = [ + escapeBatchMetacharacters(bin), + ...args.map(escapeBatchArgument), + ].join(' ') return { command: getBazelEnvironmentValue(env, 'ComSpec') ?? 'cmd.exe', args: ['/d', '/v:off', '/s', '/c', `"${script}"`], windowsVerbatimArguments: true, } } - return { command: bin, args } + return { command: bin, args, windowsVerbatimArguments: false } } export function spawnBazel(bin: string, args: string[], options: SpawnOptions) { @@ -140,11 +160,9 @@ export function spawnBazel(bin: string, args: string[], options: SpawnOptions) { const invocation = buildBazelProcessInvocation(bin, args, env) return spawn(invocation.command, invocation.args, { ...options, - ...('windowsVerbatimArguments' in invocation - ? { windowsVerbatimArguments: invocation.windowsVerbatimArguments } - : {}), env, shell: false, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }) } diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index f2812a25b..3daa4179b 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -44,6 +44,13 @@ if (process.env.SOCKET_BAZEL_RECORD) { fs.writeFileSync(process.env.SOCKET_BAZEL process.stdout.write(JSON.stringify(record)) ` +function readServerCalls(record: string): unknown[] { + return readFileSync(record, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)) +} + describe('Bazel process invocation', () => { let cwd: string let launcher: string @@ -93,6 +100,8 @@ describe('Bazel process invocation', () => { await cmdShim(launcher, shim) const bin = await resolveBazelBinary( process.platform === 'win32' ? `${shim}.cmd` : shim, + getBazelEnvironment(), + cwd, ) const result = await spawnBazel(bin, ARGUMENTS, { cwd }) expect(JSON.parse(result.stdout)).toMatchObject({ args: ARGUMENTS, cwd }) @@ -136,12 +145,31 @@ describe('Bazel process invocation', () => { expect(key && env[key]).toBe('last value') }) - it('shuts down with the same workspace, startup flags, and environment', async () => { - const record = path.join(cwd, 'shutdown.json') + function writeServerLauncher(outputBase: string): string { + const record = path.join(cwd, 'server.jsonl') + writeFileSync( + launcher, + `const fs = require('node:fs') +const args = process.argv.slice(2) +fs.appendFileSync(${JSON.stringify(record)}, JSON.stringify({ args, cwd: process.cwd(), marker: process.env.SOCKET_BAZEL_LITERAL }) + '\\n') +if (args.includes('info')) { process.stdout.write(${JSON.stringify(outputBase)} + '\\n') } +`, + ) + return record + } + + it('shuts down its own server with the same workspace, startup flags, and environment', async () => { const root = makeBazelOutputUserRoot( getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), cwd, ) + const outputBase = path.join(root, 'workspace hash') + mkdirSync(outputBase) + const record = writeServerLauncher(outputBase) + const startupFlags = [ + `--bazelrc=${path.join(cwd, 'custom rc')}`, + `--output_user_root=${root}`, + ] expect( await cleanUpBazelServer({ bin: launcher, @@ -149,24 +177,48 @@ describe('Bazel process invocation', () => { invocationFlags: [], outputUserRoot: root, bazelRc: path.join(cwd, 'custom rc'), - env: { - SOCKET_BAZEL_LITERAL: 'shutdown environment', - SOCKET_BAZEL_RECORD: record, - }, + env: { SOCKET_BAZEL_LITERAL: 'shutdown environment' }, }), ).toBe(true) - expect(JSON.parse(readFileSync(record, 'utf8'))).toEqual({ - args: [ - `--bazelrc=${path.join(cwd, 'custom rc')}`, - `--output_user_root=${root}`, - 'shutdown', - ], - cwd, - marker: 'shutdown environment', - }) + expect(readServerCalls(record)).toEqual([ + { + args: [...startupFlags, 'info', 'output_base'], + cwd, + marker: 'shutdown environment', + }, + { + args: [...startupFlags, 'shutdown'], + cwd, + marker: 'shutdown environment', + }, + ]) expect(await removeBazelTempdir(root)).toBe(true) }) + it('leaves a server selected by an rc output base alone', async () => { + const root = makeBazelOutputUserRoot( + getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), + cwd, + ) + const callerOutputBase = path.join(cwd, 'caller output base') + mkdirSync(callerOutputBase) + const record = writeServerLauncher(callerOutputBase) + expect( + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + }), + ).toBe(true) + expect(readServerCalls(record)).toEqual([ + expect.objectContaining({ + args: expect.arrayContaining(['info', 'output_base']), + }), + ]) + }) + it('leaves caller-owned output bases and their servers alone', async () => { expect( await cleanUpBazelServer({ diff --git a/src/commands/manifest/bazel/bazel-python-shim.mts b/src/commands/manifest/bazel/bazel-python-shim.mts index 86279b947..d0e537649 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.mts @@ -3,15 +3,11 @@ import { mkdtempSync, symlinkSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { - findBazelExecutable, - getBazelEnvironment, - getBazelEnvironmentValue, -} from './bazel-process.mts' +import { findBazelExecutable, prependBazelPath } from './bazel-process.mts' import { InputError } from '../../../utils/errors.mts' export type PythonShimResult = { - augmentedEnv: NodeJS.ProcessEnv | undefined + env: NodeJS.ProcessEnv shimDir: string | undefined } @@ -44,8 +40,8 @@ function verifyPython( } export async function provisionPythonShim( - env: NodeJS.ProcessEnv = getBazelEnvironment(), - cwd: string = process.cwd(), + env: NodeJS.ProcessEnv, + cwd: string, ): Promise { const python = await findBazelExecutable('python', env, cwd) if (python) { @@ -58,7 +54,7 @@ export async function provisionPythonShim( ) } verifyPython(python, env, cwd) - return { augmentedEnv: undefined, shimDir: undefined } + return { env, shimDir: undefined } } if (process.platform === 'win32') { throw new InputError( @@ -74,11 +70,5 @@ export async function provisionPythonShim( verifyPython(python3, env, cwd) const shimDir = mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-python-')) symlinkSync(python3, path.join(shimDir, 'python')) - return { - augmentedEnv: getBazelEnvironment({ - ...env, - PATH: `${shimDir}${path.delimiter}${getBazelEnvironmentValue(env, 'PATH') ?? ''}`, - }), - shimDir, - } + return { env: prependBazelPath(env, shimDir), shimDir } } diff --git a/src/commands/manifest/bazel/bazel-python-shim.test.mts b/src/commands/manifest/bazel/bazel-python-shim.test.mts index 157580f10..0828ecd0f 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.test.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.test.mts @@ -57,16 +57,18 @@ describe('provisionPythonShim', () => { throw new Error('Python fixture is unavailable') } if (process.platform === 'win32') { - const result = await provisionPythonShim( - getBazelEnvironment({ Path: path.dirname(python) }), - cwd, - ) - expect(result).toEqual({ augmentedEnv: undefined, shimDir: undefined }) + const env = getBazelEnvironment({ Path: path.dirname(python) }) + expect(await provisionPythonShim(env, cwd)).toEqual({ + env, + shimDir: undefined, + }) } else { symlinkSync(python, path.join(cwd, 'python')) - expect( - await provisionPythonShim(getBazelEnvironment({ PATH: cwd }), cwd), - ).toEqual({ augmentedEnv: undefined, shimDir: undefined }) + const env = getBazelEnvironment({ PATH: cwd }) + expect(await provisionPythonShim(env, cwd)).toEqual({ + env, + shimDir: undefined, + }) } await expect( provisionPythonShim( @@ -95,7 +97,7 @@ describe('provisionPythonShim', () => { shims.push(result.shimDir) expect(existsSync(path.join(result.shimDir, 'python'))).toBe(true) expect(readlinkSync(path.join(result.shimDir, 'python'))).toBe(python) - expect(result.augmentedEnv?.['PATH']).toContain(cwd) + expect(result.env['PATH']).toContain(cwd) } expect(results[0]?.shimDir).not.toBe(results[1]?.shimDir) }, diff --git a/src/commands/manifest/bazel/extract_bazel_to_maven.mts b/src/commands/manifest/bazel/extract_bazel_to_maven.mts index bf663418d..d5d26405f 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_maven.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_maven.mts @@ -5,6 +5,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { resolveBazelBinary } from './bazel-bin-detect.mts' import { + BAZEL_OUTPUT_ROOT_PREFIX, cleanUpBazelServer, makeBazelOutputUserRoot, removeBazelTempdir, @@ -610,14 +611,10 @@ async function cleanUpOutputRoot( root: string, contexts: Map, ): Promise { - let stopped = true - for (const opts of contexts.values()) { - // eslint-disable-next-line no-await-in-loop - if (!(await cleanUpBazelServer(opts))) { - stopped = false - } - } - return stopped ? await removeBazelTempdir(root) : false + const stopped = await Promise.all( + Array.from(contexts.values(), cleanUpBazelServer), + ) + return stopped.every(Boolean) ? await removeBazelTempdir(root) : false } // Construct the BazelQueryOptions shape used for a single workspace's @@ -625,7 +622,7 @@ async function cleanUpOutputRoot( // ESLint's consistent-function-scoping is happy; takes everything it // previously closed over as explicit params. function buildQueryOpts(args: { - baseEnv: NodeJS.ProcessEnv | undefined + baseEnv: NodeJS.ProcessEnv bin: string invocationFlags: string[] opts: ExtractBazelOptions @@ -652,7 +649,7 @@ function buildQueryOpts(args: { ...(opts.bazelOutputBase ? { bazelOutputBase: path.resolve(opts.cwd, opts.bazelOutputBase) } : {}), - ...(baseEnv ? { env: baseEnv } : {}), + env: baseEnv, verbose, } } @@ -684,7 +681,7 @@ export async function extractBazelToMaven( bin = await resolveBazelBinary(opts.bin, baseEnv, cwd) const shim = await provisionPythonShim(baseEnv, cwd) pythonShimDir = shim.shimDir - baseEnv = shim.augmentedEnv ?? baseEnv + baseEnv = shim.env outputUserRoot = makeBazelOutputUserRoot(baseEnv, cwd) } catch (e) { if (pythonShimDir) { @@ -743,11 +740,11 @@ export async function extractBazelToMaven( // callers EXTEND it via ignoreDirNames/ignoreDirPrefixes. const ignoreDirNames = new Set([ ...DEFAULT_BAZEL_WALKER_IGNORE_DIR_NAMES, - path.basename(outputUserRoot), ...(opts.ignoreDirNames ?? []), ]) const ignoreDirPrefixes = [ ...DEFAULT_BAZEL_WALKER_IGNORE_DIR_PREFIXES, + BAZEL_OUTPUT_ROOT_PREFIX, ...(opts.ignoreDirPrefixes ?? []), ] const workspaceRoots = findWorkspaceRoots({ @@ -1192,14 +1189,12 @@ export async function extractBazelToMaven( workspaceOutcomes, } } finally { - let cleaned = true - for (const [root, contexts] of mintedRoots) { - // eslint-disable-next-line no-await-in-loop - if (!(await cleanUpOutputRoot(root, contexts))) { - cleaned = false - } - } - if (pythonShimDir && cleaned) { + const cleaned = await Promise.all( + Array.from(mintedRoots, ([root, contexts]) => + cleanUpOutputRoot(root, contexts), + ), + ) + if (pythonShimDir && cleaned.every(Boolean)) { await removeBazelTempdir(pythonShimDir) } } diff --git a/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts b/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts index 393804098..b0ccca669 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_maven.test.mts @@ -27,8 +27,8 @@ vi.mock('./bazel-java-shim.mts', () => ({ ensureJavaOnPath: vi.fn(), })) vi.mock('./bazel-python-shim.mts', () => ({ - provisionPythonShim: vi.fn(async () => ({ - augmentedEnv: undefined, + provisionPythonShim: vi.fn(async (env: NodeJS.ProcessEnv) => ({ + env, shimDir: undefined, })), })) @@ -516,6 +516,7 @@ Fetched repositories: expect(names).toContain('.git') expect(names).toContain('.socket-auto-manifest') expect(call.ignoreDirPrefixes).toContain('bazel-') + expect(call.ignoreDirPrefixes).toContain('socket-bazel-') }) it('extends (not replaces) the default prune policy with caller-supplied dirs', async () => { diff --git a/src/commands/manifest/bazel/extract_bazel_to_pypi.mts b/src/commands/manifest/bazel/extract_bazel_to_pypi.mts index 8a774e69b..6895fb761 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_pypi.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_pypi.mts @@ -105,7 +105,7 @@ export async function extractBazelToPypi( const env = getBazelEnvironment(opts.env) const shim = await provisionPythonShim(env, cwd) pythonShimDir = shim.shimDir - const baseEnv = shim.augmentedEnv ?? env + const baseEnv = shim.env // Step 1: workspace detection. const mode = detectWorkspaceMode(cwd) @@ -137,7 +137,7 @@ export async function extractBazelToPypi( ...(opts.bazelOutputBase ? { bazelOutputBase: opts.bazelOutputBase } : {}), - ...(baseEnv ? { env: baseEnv } : {}), + env: baseEnv, verbose, } diff --git a/src/commands/manifest/bazel/extract_bazel_to_pypi.test.mts b/src/commands/manifest/bazel/extract_bazel_to_pypi.test.mts index df3d4a1b0..71a672452 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_pypi.test.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_pypi.test.mts @@ -53,8 +53,8 @@ vi.mock('./bazel-output-base-check.mts', () => ({ validateOutputBase: vi.fn(), })) vi.mock('./bazel-python-shim.mts', () => ({ - provisionPythonShim: vi.fn(async () => ({ - augmentedEnv: undefined, + provisionPythonShim: vi.fn(async (env: NodeJS.ProcessEnv) => ({ + env, shimDir: undefined, })), })) From aa20faf8df6f4a84e2b3f74839bf172e0f7a48bd Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 27 Oct 2026 07:06:50 +0100 Subject: [PATCH 5/8] fix: stop servers the scan started, keep inputs Shut down any Bazel server whose install or output base is in the scan's own root, including one started in an explicit output base, and always remove the root afterwards, even when Bazel cannot report its bases. A python on PATH that is not Python 3 falls back to python3. Quotes in Bazel flags only open at the start of a value or after =, so apostrophes stay literal. A relative --bazel resolves against the current directory again. The Windows Bazel workflow runs only for Bazel changes. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/bazel-windows.yml | 12 ++ CHANGELOG.md | 5 +- .../manifest/bazel/bazel-bin-detect.mts | 2 +- src/commands/manifest/bazel/bazel-cleanup.mts | 40 ++++-- .../manifest/bazel/bazel-process.test.mts | 130 +++++++++++------- .../manifest/bazel/bazel-python-shim.mts | 37 +++-- .../manifest/bazel/bazel-python-shim.test.mts | 20 +++ .../manifest/bazel/bazel-query-runner.mts | 5 +- .../manifest/bazel/cmd-manifest-bazel.mts | 12 +- .../manifest/bazel/extract_bazel_to_maven.mts | 20 +-- 10 files changed, 183 insertions(+), 100 deletions(-) diff --git a/.github/workflows/bazel-windows.yml b/.github/workflows/bazel-windows.yml index b2ca78833..6d5523336 100644 --- a/.github/workflows/bazel-windows.yml +++ b/.github/workflows/bazel-windows.yml @@ -3,8 +3,20 @@ name: Bazel on Windows on: pull_request: branches: [main, v1.x] + paths: + - .github/workflows/bazel-windows.yml + - package.json + - pnpm-lock.yaml + - src/commands/manifest/bazel/** + - test/fixtures/manifest-bazel/** push: branches: [main, v1.x] + paths: + - .github/workflows/bazel-windows.yml + - package.json + - pnpm-lock.yaml + - src/commands/manifest/bazel/** + - test/fixtures/manifest-bazel/** permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index b631aeff1..ac6f5baf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Bazel Maven extraction handles native Windows executables, batch launchers, and JavaScript entry points while preserving arguments and the selected executable path. - Bazel prerequisite checks use the extraction environment and verify Python 3. Windows requires a working `python.exe` without creating a Python symlink. -- Quoted Bazel flags preserve spaces and Windows backslashes. Unclosed quotes produce an error. -- Bazel cleanup uses the extraction workspace, environment, and startup options, retries temporary file locks, and leaves caller-owned output bases and their servers alone. +- Quoted Bazel flags preserve spaces and Windows backslashes. Quotes open at the start of a value or after `=`, so apostrophes inside a value stay literal. Unclosed quotes produce an error. +- Bazel cleanup uses the extraction workspace, environment, and startup options, retries temporary file locks, and only stops Bazel servers the scan started. +- A relative `--bazel` path resolves against the current directory. A relative `bazel` path in `socket.json` resolves against the scanned directory. ## [1.10.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.1) - 2026-10-23 diff --git a/src/commands/manifest/bazel/bazel-bin-detect.mts b/src/commands/manifest/bazel/bazel-bin-detect.mts index 24f325e32..c18e1f7eb 100644 --- a/src/commands/manifest/bazel/bazel-bin-detect.mts +++ b/src/commands/manifest/bazel/bazel-bin-detect.mts @@ -25,7 +25,7 @@ export async function resolveBazelBinary( const resolved = path.resolve(cwd, explicit) if (!existsSync(resolved)) { throw new InputError( - `--bazel path does not exist: ${explicit}. Install bazelisk or bazel, or pass an existing path via --bazel.`, + `--bazel path does not exist: ${resolved}. Install bazelisk or bazel, or pass an existing path via --bazel.`, ) } return resolveBazelExecutable(resolved) diff --git a/src/commands/manifest/bazel/bazel-cleanup.mts b/src/commands/manifest/bazel/bazel-cleanup.mts index e29505c2d..44c1542dd 100644 --- a/src/commands/manifest/bazel/bazel-cleanup.mts +++ b/src/commands/manifest/bazel/bazel-cleanup.mts @@ -83,39 +83,53 @@ async function makeDirectoriesWritable(root: string): Promise { } } +export async function cleanUpBazelOutputRoot( + root: string, + contexts: Map, +): Promise { + await Promise.all(Array.from(contexts.values(), cleanUpBazelServer)) + return await removeBazelTempdir(root) +} + export async function cleanUpBazelServer( opts: BazelQueryOptions, -): Promise { - if (!opts.outputUserRoot || opts.bazelOutputBase) { - return true +): Promise { + const root = opts.outputUserRoot + if (!root) { + return } const startupFlags = buildBazelStartupFlags(opts) const spawnOptions = { cwd: opts.cwd, ...(opts.env ? { env: opts.env } : {}), - signal: new AbortController().signal, timeout: 10_000, } try { - // An rc file can set `startup --output_base`, which points the scan at a server it does not own. + // Only this scan can have started a server whose install or output base is in its fresh root. + // An rc file can point the scan at a caller's install and output base. const info = await spawnBazel( opts.bin, - [...startupFlags, 'info', 'output_base'], + [...startupFlags, 'info', 'install_base', 'output_base'], spawnOptions, ) - if (!isPathInside(info.stdout.trim(), opts.outputUserRoot)) { - return true + if ( + !parseBazelInfoPaths(info.stdout).some(dir => isPathInside(dir, root)) + ) { + return } await spawnBazel(opts.bin, [...startupFlags, 'shutdown'], spawnOptions) - return true } catch (error) { - logger.warn( - `Bazel shutdown failed in ${opts.cwd}. Keeping ${opts.outputUserRoot} for manual cleanup. ${getErrorCause(error)}`, - ) - return false + logger.warn(`Bazel shutdown failed in ${opts.cwd}. ${getErrorCause(error)}`) } } +function parseBazelInfoPaths(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map(line => line.slice(line.indexOf(': ') + 2).trim()) + .filter(Boolean) +} + function isPathInside(child: string, parent: string): boolean { const relative = path.relative( realpathSync.native(parent), diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index 3daa4179b..38e966609 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -1,6 +1,7 @@ import { chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -18,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveBazelBinary } from './bazel-bin-detect.mts' import { + cleanUpBazelOutputRoot, cleanUpBazelServer, makeBazelOutputUserRoot, removeBazelTempdir, @@ -145,44 +147,52 @@ describe('Bazel process invocation', () => { expect(key && env[key]).toBe('last value') }) - function writeServerLauncher(outputBase: string): string { + function writeServerLauncher( + installBase: string, + outputBase: string, + ): string { const record = path.join(cwd, 'server.jsonl') writeFileSync( launcher, `const fs = require('node:fs') const args = process.argv.slice(2) fs.appendFileSync(${JSON.stringify(record)}, JSON.stringify({ args, cwd: process.cwd(), marker: process.env.SOCKET_BAZEL_LITERAL }) + '\\n') -if (args.includes('info')) { process.stdout.write(${JSON.stringify(outputBase)} + '\\n') } +if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base: ${installBase}\noutput_base: ${outputBase}\n`)}) } `, ) return record } - it('shuts down its own server with the same workspace, startup flags, and environment', async () => { + function makeServerRoot(): { root: string; installBase: string } { const root = makeBazelOutputUserRoot( getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), cwd, ) + const installBase = path.join(root, 'install', 'hash') + mkdirSync(installBase, { recursive: true }) + return { root, installBase } + } + + it('shuts down its own server with the same workspace, startup flags, and environment', async () => { + const { installBase, root } = makeServerRoot() const outputBase = path.join(root, 'workspace hash') mkdirSync(outputBase) - const record = writeServerLauncher(outputBase) + const record = writeServerLauncher(installBase, outputBase) const startupFlags = [ `--bazelrc=${path.join(cwd, 'custom rc')}`, `--output_user_root=${root}`, ] - expect( - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: root, - bazelRc: path.join(cwd, 'custom rc'), - env: { SOCKET_BAZEL_LITERAL: 'shutdown environment' }, - }), - ).toBe(true) + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + env: { SOCKET_BAZEL_LITERAL: 'shutdown environment' }, + }) expect(readServerCalls(record)).toEqual([ { - args: [...startupFlags, 'info', 'output_base'], + args: [...startupFlags, 'info', 'install_base', 'output_base'], cwd, marker: 'shutdown environment', }, @@ -195,52 +205,68 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(outputBase)} expect(await removeBazelTempdir(root)).toBe(true) }) - it('leaves a server selected by an rc output base alone', async () => { - const root = makeBazelOutputUserRoot( - getBazelEnvironment({ SOCKET_BAZEL_OUTPUT_ROOT: cwd }), - cwd, - ) - const callerOutputBase = path.join(cwd, 'caller output base') + it('shuts down the server it started in an explicit output base', async () => { + const { installBase, root } = makeServerRoot() + const callerOutputBase = path.join(cwd, 'caller cache') mkdirSync(callerOutputBase) - const record = writeServerLauncher(callerOutputBase) - expect( - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: root, - bazelRc: path.join(cwd, 'custom rc'), - }), - ).toBe(true) + const record = writeServerLauncher(installBase, callerOutputBase) + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelOutputBase: callerOutputBase, + }) expect(readServerCalls(record)).toEqual([ expect.objectContaining({ - args: expect.arrayContaining(['info', 'output_base']), + args: expect.arrayContaining(['info', 'install_base', 'output_base']), + }), + expect.objectContaining({ + args: [ + `--output_user_root=${root}`, + `--output_base=${callerOutputBase}`, + 'shutdown', + ], }), ]) }) - it('leaves caller-owned output bases and their servers alone', async () => { - expect( - await cleanUpBazelServer({ - bin: path.join(cwd, 'must not run'), - cwd, - invocationFlags: [], - outputUserRoot: cwd, - bazelOutputBase: path.join(cwd, 'caller cache'), + it('leaves a server selected by an rc install and output base alone', async () => { + const { root } = makeServerRoot() + const callerInstallBase = path.join(cwd, 'caller install base') + const callerOutputBase = path.join(cwd, 'caller output base') + mkdirSync(callerInstallBase) + mkdirSync(callerOutputBase) + const record = writeServerLauncher(callerInstallBase, callerOutputBase) + await cleanUpBazelServer({ + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + }) + expect(readServerCalls(record)).toEqual([ + expect.objectContaining({ + args: expect.arrayContaining(['info', 'install_base', 'output_base']), }), - ).toBe(true) + ]) }) - it('returns a cleanup failure when shutdown fails', async () => { + it('removes the output root when Bazel cannot report its bases', async () => { + const { root } = makeServerRoot() writeFileSync(launcher, 'process.exit(1)\n') expect( - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: cwd, - }), - ).toBe(false) + await cleanUpBazelOutputRoot( + root, + new Map([ + [ + cwd, + { bin: launcher, cwd, invocationFlags: [], outputUserRoot: root }, + ], + ]), + ), + ).toBe(true) + expect(existsSync(root)).toBe(false) }) it.skipIf(process.platform === 'win32')( @@ -316,6 +342,12 @@ describe('splitBazelFlags', () => { ]) }) + it('keeps apostrophes inside a value literal', () => { + expect( + splitBazelFlags("--action_env=GREETING=don't --define=owner=O'Brien"), + ).toEqual(["--action_env=GREETING=don't", "--define=owner=O'Brien"]) + }) + it('reports malformed quoting', () => { expect(() => splitBazelFlags('--define="missing end')).toThrow( /unterminated quote/, diff --git a/src/commands/manifest/bazel/bazel-python-shim.mts b/src/commands/manifest/bazel/bazel-python-shim.mts index d0e537649..20951b9b4 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.mts @@ -11,11 +11,11 @@ export type PythonShimResult = { shimDir: string | undefined } -function verifyPython( +function isPython3( executable: string, env: NodeJS.ProcessEnv, cwd: string, -): void { +): boolean { try { const version = execFileSync( executable, @@ -28,13 +28,15 @@ function verifyPython( timeout: 10_000, }, ) - if (version.trim() === '3') { - return - } + return version.trim() === '3' } catch { // Windows application aliases can resolve without a working interpreter. + return false } - throw new InputError( +} + +function notPython3Error(executable: string): InputError { + return new InputError( `Python 3 is required for Bazel manifest extraction. ${executable} did not run as Python 3. Install Python 3 and ensure python${process.platform === 'win32' ? '.exe' : ''} is on PATH.`, ) } @@ -53,21 +55,28 @@ export async function provisionPythonShim( 'Bazel on Windows requires a working Python 3 python.exe on PATH. Python batch launchers are not supported.', ) } - verifyPython(python, env, cwd) - return { env, shimDir: undefined } - } - if (process.platform === 'win32') { + if (isPython3(python, env, cwd)) { + return { env, shimDir: undefined } + } + if (process.platform === 'win32') { + throw notPython3Error(python) + } + } else if (process.platform === 'win32') { throw new InputError( 'Bazel on Windows requires a working Python 3 python.exe on PATH. Install Python 3 with the PATH option enabled, then retry.', ) } const python3 = await findBazelExecutable('python3', env, cwd) if (!python3) { - throw new InputError( - 'Neither python nor python3 was found on PATH. Install Python 3 and ensure it is on PATH, then retry.', - ) + throw python + ? notPython3Error(python) + : new InputError( + 'Neither python nor python3 was found on PATH. Install Python 3 and ensure it is on PATH, then retry.', + ) + } + if (!isPython3(python3, env, cwd)) { + throw notPython3Error(python3) } - verifyPython(python3, env, cwd) const shimDir = mkdtempSync(path.join(os.tmpdir(), 'socket-bazel-python-')) symlinkSync(python3, path.join(shimDir, 'python')) return { env: prependBazelPath(env, shimDir), shimDir } diff --git a/src/commands/manifest/bazel/bazel-python-shim.test.mts b/src/commands/manifest/bazel/bazel-python-shim.test.mts index 0828ecd0f..af1d8446d 100644 --- a/src/commands/manifest/bazel/bazel-python-shim.test.mts +++ b/src/commands/manifest/bazel/bazel-python-shim.test.mts @@ -102,4 +102,24 @@ describe('provisionPythonShim', () => { expect(results[0]?.shimDir).not.toBe(results[1]?.shimDir) }, ) + + it.skipIf(!python || process.platform === 'win32')( + 'uses python3 when python is not Python 3', + async () => { + if (!python) { + throw new Error('Python fixture is unavailable') + } + copyFileSync(process.execPath, path.join(cwd, 'python')) + symlinkSync(python, path.join(cwd, 'python3')) + const result = await provisionPythonShim( + getBazelEnvironment({ PATH: cwd }), + cwd, + ) + if (!result.shimDir) { + throw new Error('Expected a Python shim') + } + shims.push(result.shimDir) + expect(readlinkSync(path.join(result.shimDir, 'python'))).toBe(python) + }, + ) }) diff --git a/src/commands/manifest/bazel/bazel-query-runner.mts b/src/commands/manifest/bazel/bazel-query-runner.mts index 736aea40f..470f1cb28 100644 --- a/src/commands/manifest/bazel/bazel-query-runner.mts +++ b/src/commands/manifest/bazel/bazel-query-runner.mts @@ -60,7 +60,10 @@ export function splitBazelFlags(flags: string | undefined): string[] { } else { argument += character } - } else if (character === '"' || character === "'") { + } else if ( + (character === '"' || character === "'") && + (!started || argument.endsWith('=')) + ) { quote = character started = true } else if (/\s/.test(character)) { diff --git a/src/commands/manifest/bazel/cmd-manifest-bazel.mts b/src/commands/manifest/bazel/cmd-manifest-bazel.mts index 9f6bd23ec..c4bac5871 100644 --- a/src/commands/manifest/bazel/cmd-manifest-bazel.mts +++ b/src/commands/manifest/bazel/cmd-manifest-bazel.mts @@ -93,11 +93,11 @@ const config: CliCommandConfig = { Set SOCKET_BAZEL_OUTPUT_ROOT to a short, writable parent directory, such as C:/sb, to shorten connector-owned Bazel output paths. Each invocation gets a separate directory. An explicit --bazel-output-base - takes precedence for Bazel's cache and stays under your control, - including server shutdown. + takes precedence for Bazel's cache and is kept after the scan. The + scan only stops Bazel servers it started. - In --bazel-flags, single or double quotes group values containing - spaces. Backslashes stay literal. Double a matching quote to include + In --bazel-flags, single or double quotes at the start of a value or + after = group values containing spaces. Backslashes stay literal. Double a matching quote to include it within a quoted value. Shell variables are not expanded. Maven hub discovery: under Bzlmod, hubs are enumerated from @@ -279,7 +279,9 @@ async function run( let perRepoTimeout = cli.flags['perRepoTimeout'] as number | undefined // Set defaults for any flag/arg that is not given. Check socket.json first. - if (!bazel) { + if (bazel) { + bazel = path.resolve(process.cwd(), String(bazel)) + } else { const defaultBazel = sockJson.defaults?.manifest?.bazel?.bazel ?? sockJson.defaults?.manifest?.bazel?.bin diff --git a/src/commands/manifest/bazel/extract_bazel_to_maven.mts b/src/commands/manifest/bazel/extract_bazel_to_maven.mts index d5d26405f..e71814dae 100644 --- a/src/commands/manifest/bazel/extract_bazel_to_maven.mts +++ b/src/commands/manifest/bazel/extract_bazel_to_maven.mts @@ -6,7 +6,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { resolveBazelBinary } from './bazel-bin-detect.mts' import { BAZEL_OUTPUT_ROOT_PREFIX, - cleanUpBazelServer, + cleanUpBazelOutputRoot, makeBazelOutputUserRoot, removeBazelTempdir, } from './bazel-cleanup.mts' @@ -607,16 +607,6 @@ async function discoverCandidatesForWorkspace( return { candidates, discoveryIndeterminate, indeterminateProbes } } -async function cleanUpOutputRoot( - root: string, - contexts: Map, -): Promise { - const stopped = await Promise.all( - Array.from(contexts.values(), cleanUpBazelServer), - ) - return stopped.every(Boolean) ? await removeBazelTempdir(root) : false -} - // Construct the BazelQueryOptions shape used for a single workspace's // queries. Lifted to module scope (out of the per-workspace loop) so // ESLint's consistent-function-scoping is happy; takes everything it @@ -912,7 +902,7 @@ export async function extractBazelToMaven( state: 'failed', }) // eslint-disable-next-line no-await-in-loop - const cleaned = await cleanUpOutputRoot( + const cleaned = await cleanUpBazelOutputRoot( outputUserRoot, mintedRoots.get(outputUserRoot) ?? new Map(), ) @@ -1189,12 +1179,12 @@ export async function extractBazelToMaven( workspaceOutcomes, } } finally { - const cleaned = await Promise.all( + await Promise.all( Array.from(mintedRoots, ([root, contexts]) => - cleanUpOutputRoot(root, contexts), + cleanUpBazelOutputRoot(root, contexts), ), ) - if (pythonShimDir && cleaned.every(Boolean)) { + if (pythonShimDir) { await removeBazelTempdir(pythonShimDir) } } From 33305d7ed314dff8a89e5428a72af2e449416139 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 27 Oct 2026 14:33:01 +0100 Subject: [PATCH 6/8] fix: stop launcher trees and verify server exit A Bazel timeout or cancellation now kills every process a launcher started, through a process group on Unix and taskkill on Windows. If something still holds the output pipes, the call settles after a short grace period. Timeouts surface as BazelTimeoutError. Cleanup runs under its own time limit, so it still works after Ctrl-C aborts the scan. It reads the pid of each server the scan owns before shutdown, waits for that server to exit, and kills it if shutdown left it running. The output root is kept only while an owned server is still alive. Real Bazel tests cover timeout and cancellation behind a JavaScript launcher, and behind a batch launcher on Windows. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 1 + src/commands/manifest/bazel/bazel-cleanup.mts | 145 ++++++++++-- src/commands/manifest/bazel/bazel-cquery.mts | 16 +- .../manifest/bazel/bazel-cquery.test.mts | 51 ++-- .../manifest/bazel/bazel-native.e2e.test.mts | 154 ++++++++++++- src/commands/manifest/bazel/bazel-process.mts | 103 ++++++++- .../manifest/bazel/bazel-process.test.mts | 217 +++++++++++++++--- 7 files changed, 603 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6f5baf6..17243add4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Bazel prerequisite checks use the extraction environment and verify Python 3. Windows requires a working `python.exe` without creating a Python symlink. - Quoted Bazel flags preserve spaces and Windows backslashes. Quotes open at the start of a value or after `=`, so apostrophes inside a value stay literal. Unclosed quotes produce an error. - Bazel cleanup uses the extraction workspace, environment, and startup options, retries temporary file locks, and only stops Bazel servers the scan started. +- Bazel timeouts and cancellation stop every process a Bazel launcher started. Cleanup still runs after Ctrl-C and keeps temporary output until the scan's own Bazel server has exited. - A relative `--bazel` path resolves against the current directory. A relative `bazel` path in `socket.json` resolves against the scanned directory. ## [1.10.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.1) - 2026-10-23 diff --git a/src/commands/manifest/bazel/bazel-cleanup.mts b/src/commands/manifest/bazel/bazel-cleanup.mts index 44c1542dd..0d9dd9b8b 100644 --- a/src/commands/manifest/bazel/bazel-cleanup.mts +++ b/src/commands/manifest/bazel/bazel-cleanup.mts @@ -1,6 +1,15 @@ -import { promises as fs, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { + existsSync, + promises as fs, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' import { logger } from '@socketsecurity/registry/lib/logger' @@ -83,27 +92,51 @@ async function makeDirectoriesWritable(root: string): Promise { } } +const SHUTDOWN_TIMEOUT_MS = 10_000 +const SERVER_EXIT_TIMEOUT_MS = 5_000 + export async function cleanUpBazelOutputRoot( root: string, contexts: Map, ): Promise { - await Promise.all(Array.from(contexts.values(), cleanUpBazelServer)) + // Ctrl-C aborts the scan's signal while the CLI keeps running, and cleanup must still run. + const signal = AbortSignal.timeout(3 * SHUTDOWN_TIMEOUT_MS) + const servers = new Set(findServerPids(root)) + const shutDown = await Promise.all( + Array.from(contexts.values(), opts => shutDownBazelServer(opts, signal)), + ) + for (const pid of shutDown) { + if (pid !== undefined) { + servers.add(pid) + } + } + const stopped = await Promise.all(Array.from(servers, stopBazelServer)) + if (!stopped.every(Boolean)) { + logger.warn( + `Bazel output root kept because its server is still running: ${root}`, + ) + return false + } return await removeBazelTempdir(root) } -export async function cleanUpBazelServer( +// Returns the pid of a server this scan owns, read before shutdown removes its pid file. +export async function shutDownBazelServer( opts: BazelQueryOptions, -): Promise { + signal: AbortSignal, +): Promise { const root = opts.outputUserRoot if (!root) { - return + return undefined } const startupFlags = buildBazelStartupFlags(opts) const spawnOptions = { cwd: opts.cwd, ...(opts.env ? { env: opts.env } : {}), - timeout: 10_000, + signal, + timeout: SHUTDOWN_TIMEOUT_MS, } + let pid: number | undefined try { // Only this scan can have started a server whose install or output base is in its fresh root. // An rc file can point the scan at a caller's install and output base. @@ -112,22 +145,108 @@ export async function cleanUpBazelServer( [...startupFlags, 'info', 'install_base', 'output_base'], spawnOptions, ) + const bases = parseBazelInfo(info.stdout) + const outputBase = bases.get('output_base') if ( - !parseBazelInfoPaths(info.stdout).some(dir => isPathInside(dir, root)) + !outputBase || + ![bases.get('install_base'), outputBase].some( + dir => dir && isPathInside(dir, root), + ) ) { - return + return undefined } + pid = readServerPid(outputBase) await spawnBazel(opts.bin, [...startupFlags, 'shutdown'], spawnOptions) } catch (error) { logger.warn(`Bazel shutdown failed in ${opts.cwd}. ${getErrorCause(error)}`) } + return pid +} + +function findServerPids(root: string): number[] { + if (!existsSync(root)) { + return [] + } + const pids: number[] = [] + for (const entry of readdirSync(root, { withFileTypes: true })) { + const pid = entry.isDirectory() + ? readServerPid(path.join(root, entry.name)) + : undefined + if (pid !== undefined) { + pids.push(pid) + } + } + return pids +} + +function readServerPid(outputBase: string): number | undefined { + let contents: string + try { + contents = readFileSync( + path.join(outputBase, 'server', 'server.pid.txt'), + 'utf8', + ) + } catch (e) { + if (isErrnoException(e) && e.code === 'ENOENT') { + return undefined + } + throw e + } + const pid = Number(contents.trim()) + return Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid) + ? pid + : undefined +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (e) { + return isErrnoException(e) && e.code === 'EPERM' + } +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + SERVER_EXIT_TIMEOUT_MS + while (isProcessAlive(pid)) { + if (Date.now() >= deadline) { + return false + } + // eslint-disable-next-line no-await-in-loop + await sleep(100) + } + return true } -function parseBazelInfoPaths(stdout: string): string[] { - return stdout - .split(/\r?\n/) - .map(line => line.slice(line.indexOf(': ') + 2).trim()) - .filter(Boolean) +// A shutdown that exits successfully can still leave the server running, for example with +// `shutdown --iff_heap_size_greater_than` in an rc file. +async function stopBazelServer(pid: number): Promise { + if (await waitForProcessExit(pid)) { + return true + } + try { + process.kill(pid, 'SIGKILL') + } catch (e) { + if (!isErrnoException(e) || e.code !== 'ESRCH') { + logger.warn( + `Bazel server ${pid} could not be stopped. ${getErrorCause(e)}`, + ) + return false + } + } + return await waitForProcessExit(pid) +} + +function parseBazelInfo(stdout: string): Map { + const values = new Map() + for (const line of stdout.split(/\r?\n/)) { + const separator = line.indexOf(': ') + if (separator > 0) { + values.set(line.slice(0, separator), line.slice(separator + 2).trim()) + } + } + return values } function isPathInside(child: string, parent: string): boolean { diff --git a/src/commands/manifest/bazel/bazel-cquery.mts b/src/commands/manifest/bazel/bazel-cquery.mts index 53f97376a..b6d25b5a2 100644 --- a/src/commands/manifest/bazel/bazel-cquery.mts +++ b/src/commands/manifest/bazel/bazel-cquery.mts @@ -24,7 +24,11 @@ * 6. Tag every artifact with `workspace:` + `repo:` * provenance via `sourceRepo`. */ -import { buildBazelStartupFlags, spawnBazel } from './bazel-process.mts' +import { + BazelTimeoutError, + buildBazelStartupFlags, + spawnBazel, +} from './bazel-process.mts' import { splitBazelFlags } from './bazel-query-runner.mts' import type { BazelQueryOptions } from './bazel-query-runner.mts' @@ -537,20 +541,12 @@ export async function runMetadataCqueryForRepo( } catch (e) { const err = e as { code?: unknown - killed?: unknown - signal?: unknown stderr?: unknown stdout?: unknown } const stdout = typeof err.stdout === 'string' ? err.stdout : '' const stderr = typeof err.stderr === 'string' ? err.stderr : '' - // On a `timeout`, the registry spawn kills the child, so Node sets - // `killed: true` and `signal: 'SIGTERM'` (or `SIGKILL`). There is no - // `timedOut` flag on the real rejection, so do not test for one. - const timedOut = - err.killed === true || - err.signal === 'SIGTERM' || - err.signal === 'SIGKILL' + const timedOut = e instanceof BazelTimeoutError const { artifacts, unresolvedLabels } = stdout ? parseCqueryJsonproto(stdout, repoName, workspaceRelPath) : { artifacts: [], unresolvedLabels: [] } diff --git a/src/commands/manifest/bazel/bazel-cquery.test.mts b/src/commands/manifest/bazel/bazel-cquery.test.mts index f26d1e7d4..cfee4198d 100644 --- a/src/commands/manifest/bazel/bazel-cquery.test.mts +++ b/src/commands/manifest/bazel/bazel-cquery.test.mts @@ -642,23 +642,28 @@ describe('runMetadataCqueryForRepo', () => { expect(r.artifacts).toEqual([]) }) - it('returns status:timeout when spawn is killed on timeout (killed=true + SIGTERM)', async () => { - // The real registry spawn does not set `timedOut`; on a `timeout` it kills - // the child, so Node populates `killed: true` and `signal: 'SIGTERM'`. - // Mock that shape so the test pins the behaviour real spawn produces. - mocked.mockRejectedValueOnce( - Object.assign(new Error('command timed out'), { - code: null, - killed: true, - signal: 'SIGTERM', - stderr: '', - stdout: '', - }), + it('returns status:timeout when Bazel outlives the timeout', async () => { + mocked.mockImplementationOnce(() => + // @ts-ignore — narrow return shape for the test. + Object.assign( + new Promise((_resolve, reject) => { + setTimeout(() => { + reject( + Object.assign(new Error('killed'), { + signal: 'SIGTERM', + stderr: '', + stdout: '', + }), + ) + }, 100) + }), + { process: {} }, + ), ) const r = await runMetadataCqueryForRepo({ opts: { bin: 'bazel', cwd: '/r', invocationFlags: [] }, repoName: 'maven', - timeoutMs: 60_000, + timeoutMs: 10, workspaceRelPath: '', workspaceRoot: '/r', }) @@ -666,24 +671,6 @@ describe('runMetadataCqueryForRepo', () => { expect(r.artifacts).toEqual([]) }) - it('returns status:timeout when spawn signals SIGTERM/SIGKILL', async () => { - mocked.mockRejectedValueOnce( - Object.assign(new Error('killed'), { - signal: 'SIGTERM', - stderr: '', - stdout: '', - }), - ) - const r = await runMetadataCqueryForRepo({ - opts: { bin: 'bazel', cwd: '/r', invocationFlags: [] }, - repoName: 'maven', - timeoutMs: 60_000, - workspaceRelPath: '', - workspaceRoot: '/r', - }) - expect(r.status).toBe('timeout') - }) - it('passes workspaceRoot as cwd and outputUserRoot as startup flag', async () => { // @ts-ignore — narrow return shape for the test. mocked.mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) @@ -700,7 +687,7 @@ describe('runMetadataCqueryForRepo', () => { workspaceRoot: '/repo/sub', }) const call = mocked.mock.calls[0]! - expect(call[2]).toMatchObject({ cwd: '/repo/sub', timeout: 60_000 }) + expect(call[2]).toMatchObject({ cwd: '/repo/sub' }) const argv = call[1] as string[] expect(argv).toContain('--output_user_root=/tmp/socket-bazel-xyz') }) diff --git a/src/commands/manifest/bazel/bazel-native.e2e.test.mts b/src/commands/manifest/bazel/bazel-native.e2e.test.mts index 0872a0262..bb200e788 100644 --- a/src/commands/manifest/bazel/bazel-native.e2e.test.mts +++ b/src/commands/manifest/bazel/bazel-native.e2e.test.mts @@ -7,6 +7,7 @@ import { readdirSync, realpathSync, rmSync, + writeFileSync, } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -16,8 +17,16 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { spawn } from '@socketsecurity/registry/lib/spawn' import { resolveBazelBinary } from './bazel-bin-detect.mts' +import { + cleanUpBazelOutputRoot, + makeBazelOutputUserRoot, +} from './bazel-cleanup.mts' import { ensureJavaOnPath } from './bazel-java-shim.mts' -import { getBazelEnvironment, spawnBazel } from './bazel-process.mts' +import { + BazelTimeoutError, + getBazelEnvironment, + spawnBazel, +} from './bazel-process.mts' import { provisionPythonShim } from './bazel-python-shim.mts' import { extractBazelToMaven } from './extract_bazel_to_maven.mts' @@ -181,3 +190,146 @@ describe.skipIf(process.env['SOCKET_BAZEL_INTEGRATION'] !== '1')( } }, ) + +const SLOW_REPOSITORY_RULE = `def _slow_repo_impl(ctx): + if ctx.os.name.startswith("windows"): + ctx.execute(["cmd.exe", "/c", "ping -n 301 127.0.0.1 > nul"], timeout = 600) + else: + ctx.execute(["sleep", "300"], timeout = 600) + ctx.file("BUILD.bazel", 'filegroup(name = "fixture")\\n') + +slow_repo = repository_rule(implementation = _slow_repo_impl) +` + +describe.skipIf(process.env['SOCKET_BAZEL_INTEGRATION'] !== '1')( + 'native Bazel process supervision', + () => { + let bin: string + let workspace: string + let root: string + + beforeAll(async () => { + bin = await resolveBazelBinary( + process.env['SOCKET_BAZEL_TEST_BIN'], + getBazelEnvironment(), + process.cwd(), + ) + }) + + beforeEach(() => { + workspace = realpathSync( + mkdtempSync(path.join(os.tmpdir(), 'bazel supervision ')), + ) + cpSync( + path.join(FIXTURES, 'workspace', '.bazelversion'), + path.join(workspace, '.bazelversion'), + ) + writeFileSync(path.join(workspace, 'BUILD.bazel'), '') + writeFileSync(path.join(workspace, 'slow.bzl'), SLOW_REPOSITORY_RULE) + writeFileSync( + path.join(workspace, 'WORKSPACE'), + 'load("//:slow.bzl", "slow_repo")\n\nslow_repo(name = "slow")\n', + ) + // A shutdown that exits successfully while the server keeps running. + writeFileSync( + path.join(workspace, '.bazelrc'), + 'common --noenable_bzlmod\nshutdown --iff_heap_size_greater_than=1000000\n', + ) + root = makeBazelOutputUserRoot(getBazelEnvironment(), workspace) + }) + + afterEach(() => { + rmSync(workspace, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }) + rmSync(root, { recursive: true, force: true, maxRetries: 5 }) + }) + + const launchers: Array<[string, () => string]> = [ + [ + 'JavaScript', + () => { + const launcher = path.join(workspace, 'launcher.cjs') + writeFileSync( + launcher, + `const { spawnSync } = require('node:child_process') +const result = spawnSync(${JSON.stringify(bin)}, process.argv.slice(2), { stdio: 'inherit' }) +process.exit(result.status ?? 1) +`, + ) + return launcher + }, + ], + ] + if (process.platform === 'win32') { + launchers.push([ + 'batch', + () => { + const launcher = path.join(workspace, 'launcher.cmd') + writeFileSync(launcher, `@"${bin}" %*\r\n`) + return launcher + }, + ]) + } + + async function expectServerStopped(launcher: string): Promise { + const server = readdirSync(root) + .map(entry => path.join(root, entry, 'server', 'server.pid.txt')) + .filter(file => existsSync(file)) + .map(file => Number(readFileSync(file, 'utf8'))) + expect(server).toHaveLength(1) + expect( + await cleanUpBazelOutputRoot( + root, + new Map([ + [ + workspace, + { + bin: launcher, + cwd: workspace, + invocationFlags: [], + outputUserRoot: root, + }, + ], + ]), + ), + ).toBe(true) + expect(existsSync(root)).toBe(false) + expect(() => process.kill(server[0] ?? 0, 0)).toThrow() + } + + for (const [kind, writeLauncher] of launchers) { + it(`stops Bazel behind a ${kind} launcher when a query times out`, async () => { + const launcher = writeLauncher() + const startedAt = Date.now() + await expect( + spawnBazel( + launcher, + [`--output_user_root=${root}`, 'query', '@slow//:fixture'], + { cwd: workspace, timeout: 30_000 }, + ), + ).rejects.toBeInstanceOf(BazelTimeoutError) + expect(Date.now() - startedAt).toBeLessThan(60_000) + await expectServerStopped(launcher) + }, 180_000) + + it(`stops Bazel behind a ${kind} launcher when cancelled`, async () => { + const launcher = writeLauncher() + const controller = new AbortController() + const startedAt = Date.now() + const query = spawnBazel( + launcher, + [`--output_user_root=${root}`, 'query', '@slow//:fixture'], + { cwd: workspace, signal: controller.signal }, + ) + setTimeout(() => controller.abort(), 30_000) + await expect(query).rejects.toMatchObject({ name: 'AbortError' }) + expect(Date.now() - startedAt).toBeLessThan(60_000) + await expectServerStopped(launcher) + }, 180_000) + } + }, +) diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts index c344691bf..60c001b87 100644 --- a/src/commands/manifest/bazel/bazel-process.mts +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -1,3 +1,4 @@ +import { execFile } from 'node:child_process' import { closeSync, existsSync, @@ -12,9 +13,11 @@ import which from 'which' import { resolveBinPathSync } from '@socketsecurity/registry/lib/bin' import { spawn } from '@socketsecurity/registry/lib/spawn' +import constants from '../../../constants.mts' import { InputError, isErrnoException } from '../../../utils/errors.mts' import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' +import type { ChildProcess } from 'node:child_process' function findEnvironmentKey( env: NodeJS.ProcessEnv, @@ -155,15 +158,111 @@ function buildBazelProcessInvocation( return { command: bin, args, windowsVerbatimArguments: false } } -export function spawnBazel(bin: string, args: string[], options: SpawnOptions) { +export class BazelTimeoutError extends Error { + readonly stdout: string + readonly stderr: string + + constructor(timeout: number, cause: unknown) { + super(`Bazel did not finish within ${timeout}ms`, { cause }) + this.stdout = readOutput(cause, 'stdout') + this.stderr = readOutput(cause, 'stderr') + } +} + +function readOutput(error: unknown, key: 'stdout' | 'stderr'): string { + if (error && typeof error === 'object' && key in error) { + const value: unknown = Reflect.get(error, key) + return typeof value === 'string' ? value : '' + } + return '' +} + +// Launchers can leave Bazel holding the output pipes after the launcher is killed. +const PIPE_RELEASE_GRACE_MS = 5_000 + +async function killProcessTree(child: ChildProcess): Promise { + const { pid } = child + if (pid === undefined) { + return + } + if (process.platform === 'win32') { + await new Promise(resolve => { + execFile('taskkill', ['/pid', String(pid), '/t', '/f'], () => resolve()) + }) + return + } + try { + process.kill(-pid, 'SIGTERM') + } catch (e) { + if (!isErrnoException(e) || e.code !== 'ESRCH') { + throw e + } + } +} + +export async function spawnBazel( + bin: string, + args: string[], + { + signal = constants.abortSignal, + timeout, + ...options + }: Omit, +) { const env = getBazelEnvironment(options.env) const invocation = buildBazelProcessInvocation(bin, args, env) - return spawn(invocation.command, invocation.args, { + const controller = new AbortController() + const pending = spawn(invocation.command, invocation.args, { ...options, + // A process group of its own lets a timeout or abort reach everything the launcher started. + detached: process.platform !== 'win32', env, shell: false, + signal: controller.signal, windowsVerbatimArguments: invocation.windowsVerbatimArguments, }) + let timedOut = false + let releaseTimer: NodeJS.Timeout | undefined + const stop = () => { + releaseTimer ??= setTimeout(() => { + pending.process.stdout?.destroy() + pending.process.stderr?.destroy() + }, PIPE_RELEASE_GRACE_MS) + return killProcessTree(pending.process) + } + const onAbort = () => { + // taskkill finds descendants through the launcher, so the launcher must outlive the tree kill. + void stop().finally(() => controller.abort(signal.reason)) + } + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + } + const timer = timeout + ? setTimeout(() => { + timedOut = true + void stop() + }, timeout) + : undefined + let result: Awaited + try { + result = await pending + } catch (e) { + if (timedOut && timeout) { + throw new BazelTimeoutError(timeout, e) + } + throw e + } finally { + clearTimeout(timer) + clearTimeout(releaseTimer) + signal.removeEventListener('abort', onAbort) + } + // Output is incomplete when the pipes were released after a timeout. + if (timedOut && timeout) { + throw new BazelTimeoutError(timeout, result) + } + return result } export function buildBazelStartupFlags(opts: { diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index 38e966609..cd9f5bf63 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -1,3 +1,4 @@ +import childProcess from 'node:child_process' import { chmodSync, copyFileSync, @@ -13,6 +14,7 @@ import { } from 'node:fs' import os from 'node:os' import path from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' import cmdShim from 'cmd-shim' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -20,11 +22,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { resolveBazelBinary } from './bazel-bin-detect.mts' import { cleanUpBazelOutputRoot, - cleanUpBazelServer, makeBazelOutputUserRoot, removeBazelTempdir, + shutDownBazelServer, } from './bazel-cleanup.mts' -import { getBazelEnvironment, spawnBazel } from './bazel-process.mts' +import { + BazelTimeoutError, + getBazelEnvironment, + spawnBazel, +} from './bazel-process.mts' import { splitBazelFlags } from './bazel-query-runner.mts' const ARGUMENTS = [ @@ -56,6 +62,7 @@ function readServerCalls(record: string): unknown[] { describe('Bazel process invocation', () => { let cwd: string let launcher: string + const servers: number[] = [] beforeEach(() => { cwd = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'bazel process '))) @@ -64,6 +71,11 @@ describe('Bazel process invocation', () => { }) afterEach(() => { + for (const pid of servers.splice(0)) { + if (isAlive(pid)) { + process.kill(pid, 'SIGKILL') + } + } rmSync(cwd, { recursive: true, force: true, maxRetries: 5 }) }) @@ -182,14 +194,17 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base `--bazelrc=${path.join(cwd, 'custom rc')}`, `--output_user_root=${root}`, ] - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: root, - bazelRc: path.join(cwd, 'custom rc'), - env: { SOCKET_BAZEL_LITERAL: 'shutdown environment' }, - }) + await shutDownBazelServer( + { + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + env: { SOCKET_BAZEL_LITERAL: 'shutdown environment' }, + }, + AbortSignal.timeout(30_000), + ) expect(readServerCalls(record)).toEqual([ { args: [...startupFlags, 'info', 'install_base', 'output_base'], @@ -210,13 +225,16 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base const callerOutputBase = path.join(cwd, 'caller cache') mkdirSync(callerOutputBase) const record = writeServerLauncher(installBase, callerOutputBase) - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: root, - bazelOutputBase: callerOutputBase, - }) + await shutDownBazelServer( + { + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelOutputBase: callerOutputBase, + }, + AbortSignal.timeout(30_000), + ) expect(readServerCalls(record)).toEqual([ expect.objectContaining({ args: expect.arrayContaining(['info', 'install_base', 'output_base']), @@ -238,13 +256,16 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base mkdirSync(callerInstallBase) mkdirSync(callerOutputBase) const record = writeServerLauncher(callerInstallBase, callerOutputBase) - await cleanUpBazelServer({ - bin: launcher, - cwd, - invocationFlags: [], - outputUserRoot: root, - bazelRc: path.join(cwd, 'custom rc'), - }) + await shutDownBazelServer( + { + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelRc: path.join(cwd, 'custom rc'), + }, + AbortSignal.timeout(30_000), + ) expect(readServerCalls(record)).toEqual([ expect.objectContaining({ args: expect.arrayContaining(['info', 'install_base', 'output_base']), @@ -252,8 +273,9 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base ]) }) - it('removes the output root when Bazel cannot report its bases', async () => { + it('stops its own server when Bazel cannot report its bases', async () => { const { root } = makeServerRoot() + const server = startFakeServer(path.join(root, 'workspace hash')) writeFileSync(launcher, 'process.exit(1)\n') expect( await cleanUpBazelOutputRoot( @@ -266,6 +288,55 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base ]), ), ).toBe(true) + expect(isAlive(server)).toBe(false) + expect(existsSync(root)).toBe(false) + }, 30_000) + + it('stops its own server when shutdown leaves it running', async () => { + const { installBase, root } = makeServerRoot() + const callerOutputBase = path.join(cwd, 'caller cache') + const server = startFakeServer(callerOutputBase) + writeServerLauncher(installBase, callerOutputBase) + expect( + await cleanUpBazelOutputRoot( + root, + new Map([ + [ + cwd, + { + bin: launcher, + cwd, + invocationFlags: [], + outputUserRoot: root, + bazelOutputBase: callerOutputBase, + }, + ], + ]), + ), + ).toBe(true) + expect(isAlive(server)).toBe(false) + expect(existsSync(root)).toBe(false) + }, 30_000) + + it('leaves a server in a caller output base running', async () => { + const { root } = makeServerRoot() + const callerInstallBase = path.join(cwd, 'caller install base') + const callerOutputBase = path.join(cwd, 'caller output base') + mkdirSync(callerInstallBase) + const server = startFakeServer(callerOutputBase) + writeServerLauncher(callerInstallBase, callerOutputBase) + expect( + await cleanUpBazelOutputRoot( + root, + new Map([ + [ + cwd, + { bin: launcher, cwd, invocationFlags: [], outputUserRoot: root }, + ], + ]), + ), + ).toBe(true) + expect(isAlive(server)).toBe(true) expect(existsSync(root)).toBe(false) }) @@ -297,7 +368,7 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base writeFileSync(launcher, 'setInterval(() => {}, 1000)\n') await expect( spawnBazel(launcher, [], { cwd, timeout: 100 }), - ).rejects.toMatchObject({ signal: 'SIGTERM' }) + ).rejects.toBeInstanceOf(BazelTimeoutError) }) it('cancels a real subprocess', async () => { @@ -307,8 +378,102 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base controller.abort() await expect(result).rejects.toMatchObject({ name: 'AbortError' }) }) + + it('stops what a launcher started when the timeout expires', async () => { + const record = writeParentLauncher({ detached: false }) + await expect( + spawnBazel(launcher, [], { cwd, timeout: 500 }), + ).rejects.toBeInstanceOf(BazelTimeoutError) + expect(await waitForExit(readChildPid(record))).toBe(true) + }) + + it('stops what a launcher started when cancelled', async () => { + const record = writeParentLauncher({ detached: false }) + const controller = new AbortController() + const result = spawnBazel(launcher, [], { cwd, signal: controller.signal }) + await waitForFile(record) + controller.abort() + await expect(result).rejects.toMatchObject({ name: 'AbortError' }) + expect(await waitForExit(readChildPid(record))).toBe(true) + }) + + it('settles a timeout when an escaped process keeps the output open', async () => { + const record = writeParentLauncher({ detached: true }) + const startedAt = Date.now() + try { + await expect( + spawnBazel(launcher, [], { cwd, timeout: 500 }), + ).rejects.toBeInstanceOf(BazelTimeoutError) + expect(Date.now() - startedAt).toBeLessThan(15_000) + } finally { + process.kill(readChildPid(record), 'SIGKILL') + } + }, 30_000) + + function writeParentLauncher(options: { detached: boolean }): string { + const record = path.join(cwd, 'child.pid') + writeFileSync( + launcher, + `const { spawn } = require('node:child_process') +const fs = require('node:fs') +const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: ${options.detached}, stdio: 'inherit' }) +fs.writeFileSync(${JSON.stringify(record)}, String(child.pid)) +${options.detached ? 'child.unref()' : ''} +`, + ) + return record + } + + function startFakeServer(outputBase: string): number { + const server = childProcess.spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)'], + { detached: true, stdio: 'ignore' }, + ) + server.unref() + const { pid } = server + if (pid === undefined) { + throw new Error('Fake Bazel server did not start') + } + servers.push(pid) + mkdirSync(path.join(outputBase, 'server'), { recursive: true }) + writeFileSync( + path.join(outputBase, 'server', 'server.pid.txt'), + String(pid), + ) + return pid + } }) +function readChildPid(record: string): number { + return Number(readFileSync(record, 'utf8')) +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +async function waitForExit(pid: number): Promise { + const deadline = Date.now() + 5_000 + while (isAlive(pid) && Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + await sleep(50) + } + return !isAlive(pid) +} + +async function waitForFile(file: string): Promise { + while (!existsSync(file)) { + // eslint-disable-next-line no-await-in-loop + await sleep(50) + } +} + describe('splitBazelFlags', () => { it('handles existing unquoted flags and CRLF', () => { expect(splitBazelFlags('--config=ci\r\n --keep_going')).toEqual([ From 54782762957c9727cf54cf2cd974004ba599f559 Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 27 Oct 2026 14:53:30 +0100 Subject: [PATCH 7/8] fix: keep scans passing when pid files can't be read Bazel cleanup runs after a scan has produced its manifests. An unreadable server pid file made cleanup throw and failed the scan. Cleanup now warns, still shuts down the servers it knows about, and keeps the output root because it can't confirm the servers stopped. Assisted-by: Claude Code:claude-opus-5-5 --- src/commands/manifest/bazel/bazel-cleanup.mts | 13 +++++++++++- .../manifest/bazel/bazel-process.test.mts | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/commands/manifest/bazel/bazel-cleanup.mts b/src/commands/manifest/bazel/bazel-cleanup.mts index 0d9dd9b8b..74b000b77 100644 --- a/src/commands/manifest/bazel/bazel-cleanup.mts +++ b/src/commands/manifest/bazel/bazel-cleanup.mts @@ -101,7 +101,15 @@ export async function cleanUpBazelOutputRoot( ): Promise { // Ctrl-C aborts the scan's signal while the CLI keeps running, and cleanup must still run. const signal = AbortSignal.timeout(3 * SHUTDOWN_TIMEOUT_MS) - const servers = new Set(findServerPids(root)) + let listed: number[] | undefined + try { + listed = findServerPids(root) + } catch (e) { + logger.warn( + `Bazel output root kept because its servers could not be checked: ${root}. ${getErrorCause(e)}`, + ) + } + const servers = new Set(listed) const shutDown = await Promise.all( Array.from(contexts.values(), opts => shutDownBazelServer(opts, signal)), ) @@ -117,6 +125,9 @@ export async function cleanUpBazelOutputRoot( ) return false } + if (!listed) { + return false + } return await removeBazelTempdir(root) } diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index cd9f5bf63..022d859bc 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -292,6 +292,26 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base expect(existsSync(root)).toBe(false) }, 30_000) + it('keeps the output root when its server pid files cannot be read', async () => { + const { root } = makeServerRoot() + mkdirSync(path.join(root, 'workspace hash', 'server', 'server.pid.txt'), { + recursive: true, + }) + writeFileSync(launcher, 'process.exit(1)\n') + expect( + await cleanUpBazelOutputRoot( + root, + new Map([ + [ + cwd, + { bin: launcher, cwd, invocationFlags: [], outputUserRoot: root }, + ], + ]), + ), + ).toBe(false) + expect(existsSync(root)).toBe(true) + }) + it('stops its own server when shutdown leaves it running', async () => { const { installBase, root } = makeServerRoot() const callerOutputBase = path.join(cwd, 'caller cache') From 0d97adc9329d5c5775cf4f3b64a6651895e0b6be Mon Sep 17 00:00:00 2001 From: Martin Torp Date: Tue, 27 Oct 2026 15:11:16 +0100 Subject: [PATCH 8/8] fix: report cancelled Bazel runs as aborted on Windows On Windows, stopping a cancelled Bazel run kills the launcher before the abort reaches the spawn, so the run failed with the launcher's exit code. It now fails with an AbortError like on other platforms. The escaped-process test also waits for its child to exit, because Windows keeps the temporary directory locked until then. Assisted-by: Claude Code:claude-opus-5-5 --- src/commands/manifest/bazel/bazel-process.mts | 10 ++++++++++ src/commands/manifest/bazel/bazel-process.test.mts | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/commands/manifest/bazel/bazel-process.mts b/src/commands/manifest/bazel/bazel-process.mts index 60c001b87..fa0edbe29 100644 --- a/src/commands/manifest/bazel/bazel-process.mts +++ b/src/commands/manifest/bazel/bazel-process.mts @@ -177,6 +177,12 @@ function readOutput(error: unknown, key: 'stdout' | 'stderr'): string { return '' } +function makeAbortError(reason: unknown): Error { + const error = new Error('The operation was aborted', { cause: reason }) + error.name = 'AbortError' + return error +} + // Launchers can leave Bazel holding the output pipes after the launcher is killed. const PIPE_RELEASE_GRACE_MS = 5_000 @@ -252,6 +258,10 @@ export async function spawnBazel( if (timedOut && timeout) { throw new BazelTimeoutError(timeout, e) } + // On Windows the tree kill ends the launcher first, so the spawn rejects with its exit code. + if (signal.aborted && !(e instanceof Error && e.name === 'AbortError')) { + throw makeAbortError(signal.reason) + } throw e } finally { clearTimeout(timer) diff --git a/src/commands/manifest/bazel/bazel-process.test.mts b/src/commands/manifest/bazel/bazel-process.test.mts index 022d859bc..9c8d6871c 100644 --- a/src/commands/manifest/bazel/bazel-process.test.mts +++ b/src/commands/manifest/bazel/bazel-process.test.mts @@ -426,7 +426,10 @@ if (args.includes('info')) { process.stdout.write(${JSON.stringify(`install_base ).rejects.toBeInstanceOf(BazelTimeoutError) expect(Date.now() - startedAt).toBeLessThan(15_000) } finally { - process.kill(readChildPid(record), 'SIGKILL') + const pid = readChildPid(record) + process.kill(pid, 'SIGKILL') + // Windows keeps the temporary directory locked until the process has exited. + expect(await waitForExit(pid)).toBe(true) } }, 30_000)