diff --git a/packages/cli/src/utils/dlx/resolve-binary.mts b/packages/cli/src/utils/dlx/resolve-binary.mts index 6d7e8aaa1..8c724b7f3 100644 --- a/packages/cli/src/utils/dlx/resolve-binary.mts +++ b/packages/cli/src/utils/dlx/resolve-binary.mts @@ -5,6 +5,8 @@ import os from 'node:os' +import { joinAnd } from '@socketsecurity/lib/arrays' + import { getCdxgenVersion } from '../../env/cdxgen-version.mts' import { getCoanaVersion } from '../../env/coana-version.mts' import { requireOpengrepChecksum } from '../../env/opengrep-checksums.mts' @@ -167,8 +169,7 @@ export function resolveSocketPatch(): BinaryResolution { if (!assetName) { throw new Error( - `socket-patch is not available for platform ${platformKey}. ` + - `Supported platforms: ${Object.keys(SOCKET_PATCH_ASSETS).join(', ')}`, + `socket-patch has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(SOCKET_PATCH_ASSETS))}); upgrade socket-cli, build socket-patch from source, or set SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH to point at a local build`, ) } @@ -246,8 +247,7 @@ export function resolveTrivy(): BinaryResolution { const platform = os.platform() const arch = os.arch() throw new Error( - `Trivy is not available for platform ${platform}-${arch}. ` + - 'Supported platforms: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64', + `Trivy has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64); run socket-cli on a supported platform or install Trivy manually and point \`trivy\` at it on PATH`, ) } @@ -310,8 +310,7 @@ export function resolveTrufflehog(): BinaryResolution { const platform = os.platform() const arch = os.arch() throw new Error( - `TruffleHog is not available for platform ${platform}-${arch}. ` + - 'Supported platforms: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64', + `TruffleHog has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64); run socket-cli on a supported platform or install TruffleHog manually and point \`trufflehog\` at it on PATH`, ) } @@ -363,8 +362,7 @@ export function resolveOpengrep(): BinaryResolution { if (!assetName) { throw new Error( - `OpenGrep is not available for platform ${platformKey}. ` + - `Supported platforms: ${Object.keys(OPENGREP_ASSETS).join(', ')}`, + `OpenGrep has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(OPENGREP_ASSETS))}); run socket-cli on a supported platform or install OpenGrep manually and point \`opengrep\` at it on PATH`, ) } diff --git a/packages/cli/src/utils/dlx/spawn.mts b/packages/cli/src/utils/dlx/spawn.mts index 2bcc8e6f3..39b316bbd 100644 --- a/packages/cli/src/utils/dlx/spawn.mts +++ b/packages/cli/src/utils/dlx/spawn.mts @@ -22,6 +22,7 @@ import os from 'node:os' import path from 'node:path' import AdmZip from 'adm-zip' +import { joinAnd } from '@socketsecurity/lib/arrays' import { WIN32 } from '@socketsecurity/lib/constants/platform' import { downloadBinary, getDlxCachePath } from '@socketsecurity/lib/dlx/binary' import { detectExecutableType } from '@socketsecurity/lib/dlx/detect' @@ -116,14 +117,14 @@ function validatePackageName(name: string): void { if (!validNamePattern.test(name)) { throw new InputError( - `Invalid package name "${name}". Package names must contain only lowercase letters, numbers, hyphens, underscores, dots, and optionally a scope (@org/package).`, + `package name "${name}" must match /^(@scope\\/)?[a-z0-9-~][a-z0-9-._~]*$/ (lowercase letters, digits, -, _, ., ~, with optional @scope/); rename the package or check for typos`, ) } // Check for path traversal attempts. if (name.includes('..') || (name.includes('/') && !name.startsWith('@'))) { throw new InputError( - `Invalid package name "${name}". Package names cannot contain path traversal sequences.`, + `package name "${name}" contains path traversal characters (".." or a "/" outside of @scope/); pass a plain name like "lodash" or "@org/pkg"`, ) } } @@ -232,7 +233,7 @@ async function downloadGitHubReleaseBinary( } } throw new InputError( - 'Timeout waiting for another process to download GitHub release', + `timed out waiting for another socket process to finish downloading ${owner}/${repo}@${version} (${assetName}); if no other socket process is running, remove stale lock files under ${path.dirname(binaryPath)} and retry`, ) } throw e @@ -267,8 +268,7 @@ async function downloadGitHubReleaseBinary( const entryPath = path.resolve(path.join(cacheDir, entry.entryName)) if (!entryPath.startsWith(normalizedCacheDir)) { throw new InputError( - `Archive contains path traversal: ${entry.entryName}. ` + - `This may indicate a compromised release asset.`, + `archive entry "${entry.entryName}" resolves outside the cache dir (${normalizedCacheDir}) — this looks like a zip-slip attack; do NOT trust this release asset, report it to the upstream project, and delete ${result.binaryPath}`, ) } } @@ -286,8 +286,7 @@ async function downloadGitHubReleaseBinary( if (!resolvedTarget.startsWith(normalizedCacheDir)) { await fs.unlink(fullPath) throw new InputError( - `Archive contains unsafe symbolic link: ${file}. ` + - `This may indicate a compromised release asset.`, + `extracted symlink ${file} targets ${resolvedTarget} which is outside the cache dir (${normalizedCacheDir}); do NOT trust this release asset, report it to the upstream project, and delete ${cacheDir}`, ) } } @@ -298,19 +297,20 @@ async function downloadGitHubReleaseBinary( const tarPath = await whichReal('tar', { nothrow: true }) if (!tarPath || Array.isArray(tarPath)) { throw new InputError( - 'tar is required to extract GitHub release archives. Please install tar for your system.', + `tar is required to extract ${assetName} but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`, ) } await spawn(tarPath, ['-xzf', result.binaryPath, '-C', cacheDir], {}) } else { - throw new InputError(`Unsupported archive format: ${assetName}`) + throw new InputError( + `archive format of ${assetName} is not supported (expected .zip or .tar.gz / .tgz); check the asset name in bundle-tools.json and the release's actual asset list`, + ) } // Verify binary was extracted. if (!existsSync(binaryPath)) { throw new InputError( - `Binary ${binaryFileName} not found after extracting ${assetName}. ` + - `Expected at: ${binaryPath}`, + `archive ${assetName} extracted but ${binaryFileName} was not found inside (expected at ${binaryPath}); the release's archive layout may have changed — verify asset contents and update bundle-tools.json`, ) } @@ -408,7 +408,9 @@ export async function spawnCoanaDlx( // Use dlx version (resolveCoana only returns 'local' or 'dlx' types). if (resolution.type !== 'dlx') { - throw new Error('Unexpected resolution type for coana') + throw new Error( + `internal: resolveCoana returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } const result = await spawnDlx( { @@ -484,7 +486,9 @@ export async function spawnCdxgenDlx( // Use dlx version (resolveCdxgen only returns 'local' or 'dlx' types). if (resolution.type !== 'dlx') { - throw new Error('Unexpected resolution type for cdxgen') + throw new Error( + `internal: resolveCdxgen returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } return await spawnDlx( resolution.details, @@ -554,7 +558,9 @@ export async function spawnSfwDlx( // Use dlx version (resolveSfw only returns 'local' or 'dlx' types). if (resolution.type !== 'dlx') { - throw new Error('Unexpected resolution type for sfw') + throw new Error( + `internal: resolveSfw returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } return await spawnDlx( resolution.details, @@ -675,21 +681,25 @@ async function spawnToolVfs( ): Promise { if (!areExternalToolsAvailable()) { throw new Error( - `Cannot spawn ${tool} from VFS - tools not available in SEA mode`, + `cannot spawn ${tool} from VFS: external tools were not bundled into this SEA binary; rebuild the SEA with INLINED_SOCKET_CLI_INCLUDE_EXTERNAL_TOOLS=1 or run the non-SEA CLI`, ) } // Extract tools from VFS (returns paths directly). const toolPaths = await extractExternalTools() if (!toolPaths) { - throw new Error(`Failed to extract ${tool} from VFS`) + throw new Error( + `failed to extract ${tool} from VFS (extractExternalTools returned null); the embedded tool archive may be corrupt — rebuild the SEA binary`, + ) } // Get tool path. const toolPath = toolPaths[tool] if (!toolPath) { - throw new Error(`Tool path not found for ${tool}`) + throw new Error( + `VFS extraction succeeded but ${tool} was not in the output map (got: ${joinAnd(Object.keys(toolPaths)) || 'empty'}); the SEA bundle is missing ${tool} — rebuild with it included`, + ) } const { env: spawnEnv, ...dlxOptions } = { @@ -938,7 +948,9 @@ function getPythonStandaloneInfo(): { assetName: string; url: string } { platformTriple = arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc' } else { - throw new InputError(`Unsupported platform: ${platform}`) + throw new InputError( + `python-build-standalone does not ship a prebuilt for os.platform()="${platform}" (supported: darwin, linux, win32); install Python manually and point socket at it via PATH`, + ) } // Asset name format matches checksums in bundle-tools.json. @@ -1000,7 +1012,7 @@ async function downloadPython(pythonDir: string): Promise { const tarPath = await whichReal('tar', { nothrow: true }) if (!tarPath || Array.isArray(tarPath)) { throw new InputError( - 'tar is required to extract Python. Please install tar for your system.', + `tar is required to extract the Python standalone archive but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`, ) } await spawn(tarPath, ['-xzf', result.binaryPath, '-C', pythonDir], {}) @@ -1044,17 +1056,16 @@ export async function ensurePython(): Promise { export async function ensurePythonDlx(retryCount = 0): Promise { const MAX_RETRIES = 3 + const pythonDir = getPythonCachePath() + const pythonBin = getPythonBinPath(pythonDir) + const lockFile = path.join(pythonDir, '.downloading') + if (retryCount >= MAX_RETRIES) { throw new InputError( - `Failed to acquire Python installation lock after ${MAX_RETRIES} retries. ` + - 'Please check for filesystem issues or competing processes.', + `could not acquire the Python install lock after ${MAX_RETRIES} retries at ${lockFile}; another socket process may be stuck, or the lock file is stale — remove it manually and retry, or check that ${pythonDir} is writable`, ) } - const pythonDir = getPythonCachePath() - const pythonBin = getPythonBinPath(pythonDir) - const lockFile = path.join(pythonDir, '.downloading') - if (!existsSync(pythonBin)) { await safeMkdir(pythonDir, { recursive: true }) @@ -1107,7 +1118,7 @@ export async function ensurePythonDlx(retryCount = 0): Promise { } } throw new InputError( - 'Timeout waiting for Python download by another process', + `timed out after 60s waiting for another socket process to finish downloading Python to ${pythonDir}; if no other socket process is running, remove ${lockFile} and retry`, ) } throw e @@ -1118,7 +1129,7 @@ export async function ensurePythonDlx(retryCount = 0): Promise { if (!existsSync(pythonBin)) { throw new InputError( - `Python binary not found after extraction: ${pythonBin}`, + `Python archive extracted but ${pythonBin} does not exist; the standalone archive layout may have changed — check the asset contents under ${pythonDir} and update the bin-path logic in spawn.mts`, ) } @@ -1218,7 +1229,9 @@ async function downloadPyPiWheel( try { const response = await socketHttpRequest(pypiUrl) if (!response.ok) { - throw new Error(`PyPI API returned ${response.status}`) + throw new Error( + `PyPI returned HTTP ${response.status} for ${pypiUrl} (expected 200); check the package name and version, or retry if the registry is rate-limiting`, + ) } const data = response.json() as { urls?: Array<{ filename: string; url: string }> @@ -1235,14 +1248,13 @@ async function downloadPyPiWheel( // If we can't fetch from API, construct URL directly (may not work for all packages). // This is a fallback; the API approach is more reliable. throw new InputError( - `Failed to fetch PyPI package info for ${packageName}@${version}: ${getErrorCause(e)}`, + `could not fetch PyPI metadata for ${packageName}==${version} from ${pypiUrl} (${getErrorCause(e)}); check your network or proxy settings, or try again if PyPI is rate-limiting`, ) } if (!wheelUrl) { throw new InputError( - `No wheel found for ${packageName}@${version} on PyPI. ` + - 'This package may only be available as a source distribution.', + `${packageName}==${version} has no py3-none-any wheel on PyPI (only sdist available); pin to a version that ships a wheel or install from source manually`, ) } @@ -1275,8 +1287,7 @@ export async function ensureSocketPyCli( if (retryCount >= MAX_RETRIES) { throw new InputError( - `Failed to acquire Socket Python CLI installation lock after ${MAX_RETRIES} retries. ` + - 'Please check for filesystem issues or competing processes.', + `could not acquire the Socket Python CLI install lock after ${MAX_RETRIES} retries; another socket process may be stuck, or the lock file is stale — check for stale lock files under the Python cache dir and retry`, ) } @@ -1386,7 +1397,7 @@ export async function ensureSocketPyCli( }) } else { throw new InputError( - `Failed to download verified socketsecurity wheel for version ${pyCliVersion}`, + `could not download the verified socketsecurity==${pyCliVersion} wheel (downloadPyPiWheel returned null — likely a checksum mismatch or missing wheel asset); re-run with --debug for details, or bump the version in bundle-tools.json if the checksum needs refreshing`, ) } } else { @@ -1454,7 +1465,7 @@ export async function spawnSocketPyCliVfs( }) } else { throw new Error( - `Failed to download verified socketsecurity wheel for version ${pyCliVersion}`, + `failed to download socketsecurity==${pyCliVersion} wheel from PyPI (downloadPyPiWheel returned null — likely a checksum mismatch or missing py3-none-any wheel); re-run with --debug for details`, ) } } else { @@ -1673,7 +1684,9 @@ async function spawnTrivyDlx( const resolution = resolveTrivy() if (resolution.type !== 'github-release') { - throw new Error('Unexpected resolution type for trivy') + throw new Error( + `internal: resolveTrivy returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } const { env: spawnEnv, ...dlxOptions } = { @@ -1735,7 +1748,9 @@ async function spawnTrufflehogDlx( const resolution = resolveTrufflehog() if (resolution.type !== 'github-release') { - throw new Error('Unexpected resolution type for trufflehog') + throw new Error( + `internal: resolveTrufflehog returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } const { env: spawnEnv, ...dlxOptions } = { @@ -1797,7 +1812,9 @@ async function spawnOpengrepDlx( const resolution = resolveOpengrep() if (resolution.type !== 'github-release') { - throw new Error('Unexpected resolution type for opengrep') + throw new Error( + `internal: resolveOpengrep returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`, + ) } const { env: spawnEnv, ...dlxOptions } = { diff --git a/packages/cli/src/utils/dlx/vfs-extract.mts b/packages/cli/src/utils/dlx/vfs-extract.mts index e6d235fc0..db2e9fd6b 100644 --- a/packages/cli/src/utils/dlx/vfs-extract.mts +++ b/packages/cli/src/utils/dlx/vfs-extract.mts @@ -66,12 +66,14 @@ import { existsSync, promises as fs } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' +import { joinAnd } from '@socketsecurity/lib/arrays' import { debug } from '@socketsecurity/lib/debug' import { safeMkdir } from '@socketsecurity/lib/fs' import { getDefaultLogger } from '@socketsecurity/lib/logger' import { normalizePath } from '@socketsecurity/lib/paths/normalize' import { UPDATE_STORE_DIR } from '../../constants/paths.mts' +import { getErrorCause } from '../error/errors.mts' import { isSeaBinary } from '../sea/detect.mts' const logger = getDefaultLogger() @@ -273,7 +275,7 @@ async function extractTool(tool: ExternalTool): Promise { if (!processWithSmol.smol?.mount) { throw new Error( - 'process.smol.mount not available - not in node-smol SEA mode', + `process.smol.mount is undefined — extractTool("${tool}") requires a node-smol SEA build; this code path should only run inside the SEA. Check isSeaBinary() / areExternalToolsAvailable() upstream`, ) } @@ -340,12 +342,16 @@ async function extractTool(tool: ExternalTool): Promise { } if (!existsSync(extractedPath)) { - throw new Error(`Extracted tool not found at ${extractedPath}`) + throw new Error( + `process.smol.mount returned but ${extractedPath} does not exist; the VFS layout for ${tool} may have changed — check the SEA build config and the tool's expected path`, + ) } return extractedPath } catch (e) { - throw new Error(`Failed to extract ${tool} from VFS: ${e}`) + throw new Error( + `failed to extract ${tool} from the SEA VFS (${getErrorCause(e)}); the embedded tool archive may be corrupt — rebuild the SEA binary`, + ) } } @@ -550,7 +556,7 @@ export async function extractExternalTools( } } throw new Error( - 'Timeout waiting for another process to extract external tools', + `timed out waiting for another socket process to finish extracting external tools from the SEA VFS; if no other socket process is running, remove any stale lock files under the node-smol base dir and retry`, ) } throw e @@ -641,7 +647,7 @@ export async function extractExternalTools( if (Object.keys(toolPaths).length !== EXTERNAL_TOOLS.length) { const missingTools = EXTERNAL_TOOLS.filter(t => !toolPaths[t]) throw new Error( - `Failed to extract all external tools. Missing: ${missingTools.join(', ')}`, + `SEA VFS extraction returned ${Object.keys(toolPaths).length}/${EXTERNAL_TOOLS.length} tools (missing: ${joinAnd(missingTools)}); the SEA bundle is incomplete — rebuild with all external tools included`, ) } diff --git a/packages/cli/test/unit/utils/dlx/resolve-binary.test.mts b/packages/cli/test/unit/utils/dlx/resolve-binary.test.mts index dcd1948fc..02090c5fc 100644 --- a/packages/cli/test/unit/utils/dlx/resolve-binary.test.mts +++ b/packages/cli/test/unit/utils/dlx/resolve-binary.test.mts @@ -353,7 +353,7 @@ describe('binary resolution utilities', () => { ) expect(() => resolveSocketPatch()).toThrow( - 'socket-patch is not available for platform freebsd-x64', + /socket-patch has no prebuilt binary for "freebsd-x64"/, ) })