diff --git a/DOCS.md b/DOCS.md index 68d56e17..def4288b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -2177,6 +2177,8 @@ Switches (flags that take no value, such as `--vulns`, `--offline` or `--no-grap By default, the scan writes `.vibgrate/scan_result.json`. Use `--no-local-artifacts` or `--max-privacy` to suppress local JSON artifact files. +A truncated or syntactically invalid lockfile stops the scan before a dependency graph is built. The error names the file (for example `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) and tells you to regenerate it with your package manager. The message does not include the lockfile, and the process exits non-zero. A valid lockfile is scanned as before. + `vg scan` does not follow symlinks while it indexes the tree. A skipped link is named once on stderr. See [Symlinks](#symlinks). For offline drift scoring, pass `--package-manifest ` with a downloaded manifest bundle such as `https://github.com/vibgrate/manifests/latest-packages.zip`. The manifest shape, the fail-closed errors, and what offline mode skips are in [Offline scan with a package-version manifest](#offline-scan-with-a-package-version-manifest). @@ -2193,7 +2195,7 @@ pnpm-lock.yaml: unknown optional lockfile field "futureOptional"; continuing wit The path is relative to the directory you ran the command from, when the lockfile is under that directory. Otherwise the warning uses the file name only. The warning names the field. It does not print the field's value, an environment value, or an authorization header. A field name that is not a plain identifier is reported as `(name omitted)`. -A lockfile that is truncated, empty, or missing the structure its format requires still fails the command. The error names the file and the format, tells you to regenerate the file with your package manager, and the process exits non-zero. The error does not include the file's contents. +A lockfile that is truncated or not valid syntax still fails the command, including the other lockfiles the walk skips (for example `bun.lock`, `mix.lock`, `Cargo.lock`). The error names each bad file, in path order, tells you to regenerate it with your package manager, and the process exits non-zero. The error does not include the file's contents. An empty `go.sum` or `go.work.sum` is valid: the module has no dependencies. `vg scan` and `vg sbom export` follow this for npm, pnpm, yarn, poetry, uv, pdm, Pipfile, Cargo, Composer, NuGet, Swift package pins, and pub. A Gradle lock line, a Gemfile spec, and a `go.sum` line do not have optional field names. A line that does not match that format is invalid, and the command fails. @@ -2751,6 +2753,8 @@ vg build [paths...] Maps source code into a graph artifact, enabling all downstream queries (`vg show`, `vg ask`, `vg impact`, etc.). +A truncated or syntactically invalid lockfile stops the build before source files are parsed, so parse workers are not left running and a partial map is not written. The error names the file and tells you to regenerate it with your package manager. A valid lockfile builds as before. + | Flag | Default | Description | |------|---------|-------------| | `[paths...]` | `.` | Folders or files to map | diff --git a/src/cli.ts b/src/cli.ts index 0241338b..51b1bd96 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,6 +44,7 @@ import { registerHcs } from './commands/hcs.js'; import { registerReview } from './commands/review.js'; import { ConfigFileError } from './core-open/config.js'; import { LockfileParseError } from './core-open/utils/lockfile-parse.js'; +import { LockfileSyntaxError } from './engine/lockfile-guard.js'; import { UnsafeRootError } from './core-open/utils/root-safety.js'; import { CliError, ExitCode, usageError } from './util/exit.js'; import { ParseWorkerError } from './engine/pool.js'; @@ -443,6 +444,7 @@ function handleError(err: unknown): never { err instanceof CliError || err instanceof ParseWorkerError || err instanceof LockfileParseError || + err instanceof LockfileSyntaxError || err instanceof ConfigFileError || err instanceof UnsafeRootError ) { diff --git a/src/commands/build.ts b/src/commands/build.ts index e321702b..abaa1b0a 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -21,6 +21,7 @@ import { serializeGraph } from '../engine/serialize.js'; import { renderReport } from '../engine/report.js'; import { renderHtml } from '../engine/html.js'; import { UsageError, mergeExcludes } from '../engine/discover.js'; +import { LockfileSyntaxError } from '../engine/lockfile-guard.js'; import { ResourceLimitError } from '../engine/limits.js'; import { ParseWorkerError } from '../engine/pool.js'; import { UnsafeRootError } from '../core-open/utils/root-safety.js'; @@ -155,6 +156,7 @@ export async function runBuild( throw new CliError(err.message, ExitCode.ERROR); } if (err instanceof UnsafeRootError) throw new CliError(err.message, ExitCode.ERROR); + if (err instanceof LockfileSyntaxError) throw new CliError(err.message, ExitCode.ERROR); throw err; } bar?.done(); diff --git a/src/core-open/utils/lockfile-parse.ts b/src/core-open/utils/lockfile-parse.ts index c01106c3..2aea3065 100644 --- a/src/core-open/utils/lockfile-parse.ts +++ b/src/core-open/utils/lockfile-parse.ts @@ -43,6 +43,7 @@ const KIND_BY_BASENAME: Readonly> = { 'cargo.lock': 'TOML', 'yarn.lock': 'yarn.lock', 'go.sum': 'go.sum', + 'go.work.sum': 'go.sum', 'gradle.lockfile': 'gradle.lockfile', 'gemfile.lock': 'Gemfile.lock', }; @@ -204,7 +205,8 @@ function assertYarn(filePath: string, text: string): void { } function assertGoSum(filePath: string, text: string): void { - if (!text.trim()) throw new LockfileParseError(filePath, 'go.sum'); + // An empty sum is a module with no dependencies, not a truncated file. + if (!text.trim()) return; const lineRe = /^\S+\s+v\S+\s+h1:\S+$/; for (const raw of text.split('\n')) { const line = raw.trim(); diff --git a/src/engine/build.ts b/src/engine/build.ts index f0b7ecd6..79121cde 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { discover, mergeExcludes, type DiscoveredFile } from './discover.js'; +import { assertLockfilesValid } from './lockfile-guard.js'; import { parseFiles } from './pool.js'; import { resolve } from './resolve.js'; import { inheritDuties } from './duties-inherit.js'; @@ -162,6 +163,9 @@ export async function buildGraph(options: BuildOptions): Promise { const timer = new StageTimer(); timer.start('total'); const root = path.resolve(options.root); + // Before discovery and source parse workers. A bad lockfile must not start + // a worker pool or leave a partial graph behind. + assertLockfilesValid(root); const exclude = mergeExcludes(root, options.exclude); // Resolved before discovery so the walk itself stops at the corpus cap // instead of reading a filesystem root or an enormous unpack first. diff --git a/src/engine/lockfile-guard.test.ts b/src/engine/lockfile-guard.test.ts new file mode 100644 index 00000000..610c64a3 --- /dev/null +++ b/src/engine/lockfile-guard.test.ts @@ -0,0 +1,235 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildGraph } from './build.js'; +import { serializeGraph } from './serialize.js'; +import { assertLockfilesValid, LockfileSyntaxError } from './lockfile-guard.js'; + +const parseState = vi.hoisted(() => ({ n: 0 })); + +vi.mock('./pool.js', async () => { + const actual = await vi.importActual('./pool.js'); + return { + ...actual, + async parseFiles(...args: Parameters): Promise>> { + parseState.n++; + return actual.parseFiles(...args); + }, + }; +}); + +const REPO = fileURLToPath(new URL('../..', import.meta.url)); +const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url)); +const PIN = '2020-01-01T00:00:00.000Z'; +const SENTINEL = 'SENTINEL_LOCKFILE_BODY_9f3a2c'; + +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +function project(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-lockguard-')); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return dir; +} + +const VALID_PACKAGE_LOCK = JSON.stringify({ + name: 'ok', + lockfileVersion: 3, + requires: true, + packages: { '': { name: 'ok', version: '1.0.0' } }, +}); + +const TRUNCATED_PACKAGE_LOCK = `{"name":"${SENTINEL}","lockfileVersion":3,"packages":{`; + +function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, ['--import', 'tsx', CLI, ...args], { + cwd: REPO, + encoding: 'utf8', + env: { + ...process.env, + NO_COLOR: '1', + FORCE_COLOR: '0', + VIBGRATE_NO_KERNEL: '1', + VIBGRATE_DSN: '', + }, + timeout: 45_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status?: number | null; stdout?: string | Buffer; stderr?: string | Buffer }; + const text = (value: string | Buffer | undefined): string => + typeof value === 'string' ? value : value ? value.toString('utf8') : ''; + return { code: e.status ?? 1, stdout: text(e.stdout), stderr: text(e.stderr) }; + } +} + +describe('assertLockfilesValid', () => { + it('accepts this package\'s own lockfiles', () => { + expect(() => assertLockfilesValid(REPO)).not.toThrow(); + }); + + it('names a truncated package-lock.json and omits its contents', () => { + const root = project({ 'package-lock.json': TRUNCATED_PACKAGE_LOCK }); + expect(() => assertLockfilesValid(root)).toThrow(LockfileSyntaxError); + try { + assertLockfilesValid(root); + } catch (err) { + expect(err).toBeInstanceOf(LockfileSyntaxError); + const message = (err as Error).message; + expect(message).toBe( + 'invalid lockfile package-lock.json: truncated or not valid syntax. Regenerate it with your package manager.', + ); + expect(message).not.toContain(SENTINEL); + expect(message).not.toContain('lockfileVersion'); + } + }); + + it('rejects a truncated pnpm-lock.yaml, yarn.lock, and Cargo.lock', () => { + const pnpm = project({ + 'pnpm-lock.yaml': `lockfileVersion: '9.0'\npackages:\n ${SENTINEL}@1.0.0:\n resolution: {integrity: sha512-dead\n`, + }); + expect(() => assertLockfilesValid(pnpm)).toThrow(/pnpm-lock\.yaml/); + + const yarn = project({ + 'yarn.lock': `# yarn lockfile v1\n${SENTINEL}@^1.0.0:\n version "1.0.0\n`, + }); + expect(() => assertLockfilesValid(yarn)).toThrow(/yarn\.lock/); + + const cargo = project({ + 'Cargo.lock': `[[package]]\nname = "${SENTINEL}"\nversion = "1.0.0\n`, + }); + expect(() => assertLockfilesValid(cargo)).toThrow(/Cargo\.lock/); + }); + + it('rejects garbage that is not a lockfile document', () => { + const root = project({ 'pnpm-lock.yaml': ` not yaml at all ${SENTINEL}` }); + let message = ''; + try { + assertLockfilesValid(root); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain('pnpm-lock.yaml'); + expect(message).not.toContain(SENTINEL); + }); + + it('lists every bad lockfile in sorted path order', () => { + const root = project({ + 'b/pnpm-lock.yaml': ' not yaml at all', + 'a/package-lock.json': TRUNCATED_PACKAGE_LOCK, + }); + expect(() => assertLockfilesValid(root)).toThrow( + 'invalid lockfile a/package-lock.json, b/pnpm-lock.yaml: truncated or not valid syntax. Regenerate it with your package manager.', + ); + }); + + it('accepts valid lockfiles and an empty go.sum', () => { + const root = project({ + 'package-lock.json': VALID_PACKAGE_LOCK, + 'pnpm-lock.yaml': "lockfileVersion: '9.0'\n", + 'yarn.lock': '# yarn lockfile v1\nleft-pad@^1.3.0:\n version "1.3.0"\n', + 'Cargo.lock': 'version = 3\n\n[[package]]\nname = "memchr"\nversion = "2.7.4"\n', + 'go.sum': '', + 'gradle.lockfile': '# generated\nempty=\n', + 'Gemfile.lock': 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rake\n\nBUNDLED WITH\n 2.4.0\n', + 'bun.lockb': Buffer.from([0, 1, 2, 3]), + }); + expect(() => assertLockfilesValid(root)).not.toThrow(); + }); + + it('ignores a truncated lockfile that gitignore excludes', () => { + const root = project({ + '.gitignore': 'scratch/\n', + 'scratch/package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'package-lock.json': VALID_PACKAGE_LOCK, + }); + expect(() => assertLockfilesValid(root)).not.toThrow(); + }); +}); + +describe('vg scan / vg build', () => { + it('stops buildGraph before source parse workers run', async () => { + const root = project({ + 'package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'src/a.ts': 'export const a = 1;\n', + }); + const before = parseState.n; + await expect(buildGraph({ root, generatedAt: PIN, inline: true, noCache: true, noIndex: true })).rejects.toBeInstanceOf( + LockfileSyntaxError, + ); + expect(parseState.n).toBe(before); + expect(fs.existsSync(path.join(root, '.vibgrate', 'graph.json'))).toBe(false); + }); + + it('builds the same graph when a valid lockfile is present', async () => { + const files = { + 'package.json': '{"name":"ok","version":"1.0.0"}\n', + 'src/a.ts': 'export const a = 1;\n', + }; + const plain = project(files); + const locked = project({ ...files, 'package-lock.json': VALID_PACKAGE_LOCK }); + const opts = { generatedAt: PIN, inline: true, noCache: true, noIndex: true } as const; + const before = parseState.n; + const a = serializeGraph((await buildGraph({ root: plain, ...opts })).graph); + const b = serializeGraph((await buildGraph({ root: locked, ...opts })).graph); + expect(parseState.n).toBeGreaterThan(before); + expect(b).toBe(a); + }); + + it('exits non-zero from vg scan and vg build without echoing the lockfile', () => { + const root = project({ + 'package.json': '{"name":"broken","version":"1.0.0"}\n', + 'package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'src/a.ts': 'export const a = 1;\n', + }); + const expected = + 'invalid lockfile package-lock.json: truncated or not valid syntax. Regenerate it with your package manager.'; + + const scan = runCli(['--offline', 'scan', root, '--offline', '--quiet', '--no-daemon']); + expect(scan.code).not.toBe(0); + expect(scan.stderr).toContain(expected); + expect(`${scan.stdout}\n${scan.stderr}`).not.toContain(SENTINEL); + expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(false); + + const build = runCli([ + '--offline', + 'build', + '-C', + root, + '--offline', + '--quiet', + '--no-daemon', + '--no-warm', + '--no-html', + '--no-report', + ]); + expect(build.code).not.toBe(0); + expect(build.stderr).toContain(expected); + expect(`${build.stdout}\n${build.stderr}`).not.toContain(SENTINEL); + expect(fs.existsSync(path.join(root, '.vibgrate', 'graph.json'))).toBe(false); + }); + + it('still scans a project with a valid lockfile', () => { + const root = project({ + 'package.json': '{"name":"ok","version":"1.0.0"}\n', + 'package-lock.json': VALID_PACKAGE_LOCK, + 'index.js': 'module.exports = 1;\n', + }); + const scan = runCli(['--offline', 'scan', root, '--offline', '--no-graph', '--quiet', '--no-daemon']); + expect(scan.code).toBe(0); + expect(`${scan.stdout}\n${scan.stderr}`).not.toContain('truncated or not valid syntax'); + expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(true); + }); +}); diff --git a/src/engine/lockfile-guard.ts b/src/engine/lockfile-guard.ts new file mode 100644 index 00000000..02f9efe0 --- /dev/null +++ b/src/engine/lockfile-guard.ts @@ -0,0 +1,307 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import ignore from 'ignore'; +import { parse as parseToml } from 'smol-toml'; +import { parse as parseYaml } from 'yaml'; +import { assertSafeWalkRoot } from '../core-open/utils/root-safety.js'; +import { isSkippedDirName, SKIP_FILES } from './discover.js'; + +/** + * Stop `vg scan` / `vg build` when a lockfile is truncated or not valid + * syntax. Readers that swallow a parse error keep going and can emit a + * partial dependency graph (or look successful with nothing resolved). This + * check runs before source parse workers start, so a bad file never leaves + * workers running and never reaches graph construction. + * + * The message names the lockfile and says to regenerate it. Parser errors are + * discarded: they quote the source, and a lockfile must not be echoed back. + */ + +const MESSAGE_SUFFIX = 'truncated or not valid syntax. Regenerate it with your package manager.'; + +/** Generated JavaScript bundles, not lock documents. */ +const SKIP_VALIDATION = new Set(['.pnp.cjs', '.pnp.loader.mjs']); + +const JSON_LOCKS = new Set([ + 'package-lock.json', + 'npm-shrinkwrap.json', + 'composer.lock', + 'packages.lock.json', + 'package.resolved', + 'pipfile.lock', + 'bun.lock', + 'deno.lock', + '.pnp.data.json', + 'renv.lock', + 'flake.lock', + 'conan.lock', + 'module.bazel.lock', +]); + +const YAML_LOCKS = new Set([ + 'pnpm-lock.yaml', + 'pubspec.lock', + 'conda-lock.yml', + 'chart.lock', + 'stack.yaml.lock', + 'glide.lock', +]); + +const TOML_LOCKS = new Set([ + 'poetry.lock', + 'uv.lock', + 'pdm.lock', + 'cargo.lock', + 'pylock.toml', + 'gopkg.lock', + 'manifest.toml', +]); + +const BINARY_LOCKS = new Set(['bun.lockb']); + +export class LockfileSyntaxError extends Error { + readonly paths: readonly string[]; + constructor(paths: readonly string[]) { + super(`invalid lockfile ${paths.join(', ')}: ${MESSAGE_SUFFIX}`); + this.name = 'LockfileSyntaxError'; + this.paths = paths; + } +} + +/** Throw {@link LockfileSyntaxError} when any lockfile under `root` is unusable. */ +export function assertLockfilesValid(root: string): void { + const abs = path.resolve(root); + assertSafeWalkRoot(abs); + const bad = findLockfiles(abs).filter((rel) => !lockfileFileValid(abs, rel)); + if (bad.length) throw new LockfileSyntaxError(bad); +} + +function findLockfiles(root: string): string[] { + const ig = ignore(); + const gitignorePath = path.join(root, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + try { + ig.add(fs.readFileSync(gitignorePath, 'utf8')); + } catch { + // Unreadable ignore file: still walk, and skip the usual dependency dirs. + } + } + + const found: string[] = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of sorted) { + if (entry.name === '.' || entry.name === '..') continue; + const abs = path.join(dir, entry.name); + const rel = toPosix(path.relative(root, abs)); + if (!rel || rel.startsWith('..')) continue; + + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const st = fs.statSync(abs); + isDir = st.isDirectory(); + isFile = st.isFile(); + } catch { + continue; + } + // Directory links can cycle. A linked lockfile is still read. + if (isDir) continue; + } + if (isDir) { + if (isSkippedDirName(entry.name)) continue; + if (ig.ignores(`${rel}/`)) continue; + walk(abs); + continue; + } + if (!isFile) continue; + if (ig.ignores(rel)) continue; + const base = entry.name.toLowerCase(); + if (!SKIP_FILES.has(base) || SKIP_VALIDATION.has(base)) continue; + found.push(rel); + } + }; + + walk(root); + found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return found; +} + +function lockfileFileValid(root: string, rel: string): boolean { + const abs = path.join(root, rel); + const base = path.posix.basename(rel).toLowerCase(); + let buf: Buffer; + try { + buf = fs.readFileSync(abs); + } catch { + // Unreadable: nothing was parsed, so there is no partial graph to stop. + return true; + } + if (BINARY_LOCKS.has(base)) return buf.length > 0; + return !lockfileTextInvalid(base, stripBom(buf.toString('utf8'))); +} + +function lockfileTextInvalid(basename: string, text: string): boolean { + if (JSON_LOCKS.has(basename)) return jsonLockInvalid(text); + if (YAML_LOCKS.has(basename)) return yamlLockInvalid(text); + if (TOML_LOCKS.has(basename)) return tomlLockInvalid(text); + if (basename === 'yarn.lock') return yarnInvalid(text); + if (basename === 'go.sum' || basename === 'go.work.sum') return goSumInvalid(text); + if (basename === 'gradle.lockfile') return gradleInvalid(text); + if (basename === 'gemfile.lock') return gemfileInvalid(text); + return structuralInvalid(text); +} + +function jsonLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + const doc: unknown = JSON.parse(text); + return !isRecord(doc); + } catch { + return true; + } +} + +function yamlLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + return !isRecord(parseYaml(text)); + } catch { + return true; + } +} + +function tomlLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + return !isRecord(parseToml(text)); + } catch { + return true; + } +} + +/** Classic and Berry yarn.lock. A block that never reaches `version` is cut off. */ +function yarnInvalid(text: string): boolean { + if (!text.trim()) return true; + if (unbalancedDoubleQuotes(text)) return true; + let open = false; + let sawVersion = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line.trim() || /^\s*#/.test(line)) continue; + if (!/^\s/.test(line)) { + if (open && !sawVersion) return true; + if (!line.trimEnd().endsWith(':')) return true; + open = true; + sawVersion = false; + continue; + } + if (!open) return true; + if (/^\s+version:?\s+\S/.test(line)) sawVersion = true; + } + return open && !sawVersion; +} + +/** `go.sum` / `go.work.sum`. Empty is a module with no dependencies. */ +function goSumInvalid(text: string): boolean { + if (!text.trim()) return false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, '').trim(); + if (!line) continue; + if (!/^\S+\s+\S+\s+h1:\S+$/.test(line)) return true; + } + return false; +} + +function gradleInvalid(text: string): boolean { + if (!text.trim()) return true; + let sawEntry = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, '').trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('empty=')) { + sawEntry = true; + continue; + } + if (!/^[^:=\s#][^:=\s]*:[^:=\s]+:[^=\s]+=\S+/.test(line)) return true; + sawEntry = true; + } + return !sawEntry; +} + +function gemfileInvalid(text: string): boolean { + if (!text.trim()) return true; + if (unbalancedDelimiters(text)) return true; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (/^ {4}\S/.test(line) && !/^ {4}[A-Za-z0-9._-]+ \([^()]+\)$/.test(line.trimEnd())) return true; + } + return false; +} + +function structuralInvalid(text: string): boolean { + if (!text.trim()) return true; + return unbalancedDelimiters(text); +} + +function unbalancedDoubleQuotes(text: string): boolean { + let open = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') open = !open; + } + return open; +} + +function unbalancedDelimiters(text: string): boolean { + let brace = 0; + let bracket = 0; + let paren = 0; + let quote = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') quote = false; + continue; + } + if (ch === '"') { + quote = true; + continue; + } + if (ch === '{') brace++; + else if (ch === '}') brace--; + else if (ch === '[') bracket++; + else if (ch === ']') bracket--; + else if (ch === '(') paren++; + else if (ch === ')') paren--; + if (brace < 0 || bracket < 0 || paren < 0) return true; + } + return quote || brace !== 0 || bracket !== 0 || paren !== 0; +} + +function isRecord(doc: unknown): doc is Record { + return !!doc && typeof doc === 'object' && !Array.isArray(doc); +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} diff --git a/src/engine/truncated-lockfile.cli.test.ts b/src/engine/truncated-lockfile.cli.test.ts index 2011f15e..1e4e3cc4 100644 --- a/src/engine/truncated-lockfile.cli.test.ts +++ b/src/engine/truncated-lockfile.cli.test.ts @@ -58,7 +58,7 @@ describe('truncated lockfile CLI', () => { expect(res.signal).toBeNull(); expect(res.status).toBe(1); expect(res.stderr).toContain('package-lock.json'); - expect(res.stderr).toContain('truncated or invalid JSON'); + expect(res.stderr).toContain('truncated or not valid syntax'); expect(res.stderr).toContain('package manager'); expect(res.stderr).not.toContain(secret); expect(res.stderr).not.toContain('left-pad'); @@ -72,7 +72,7 @@ describe('truncated lockfile CLI', () => { expect(res.signal).toBeNull(); expect(res.status).toBe(1); expect(res.stderr).toContain('package-lock.json'); - expect(res.stderr).toContain('truncated or invalid JSON'); + expect(res.stderr).toContain('truncated or not valid syntax'); expect(res.stderr).not.toContain(secret); }, 60_000); }); diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index a47cdc20..d74d59d0 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -53,6 +53,7 @@ import { emitIngestIdLine, emitDriftScoreLine } from '../utils/ingest-id-output. import { formatUploadHttpFailure, uploadScanArtifact } from '../utils/upload.js'; import { redactForDisplay } from '../../core-open/utils/redact.js'; import { buildGraph } from '../../engine/build.js'; +import { assertLockfilesValid, LockfileSyntaxError } from '../../engine/lockfile-guard.js'; import { parseFailureWarningLines } from '../../engine/parse-warning.js'; import { writeArtifacts, resolveGraphPath } from '../../engine/artifacts.js'; import { readHaileSidecar } from '../../engine/haile/sidecar.js'; @@ -479,6 +480,15 @@ export const scanCommand = new Command('scan') throw err; } + // Before project scanners and the code-map parse workers. A truncated + // lockfile must not become a partial dependency graph or a successful exit. + try { + assertLockfilesValid(rootDir); + } catch (err) { + if (err instanceof LockfileSyntaxError) throw new CliError(err.message, ExitCode.ERROR); + throw err; + } + // Fail closed before any scan work. A missing, unreadable, or unusable // --package-manifest must stop the command with a stable, actionable // error — not a stack trace, and not a scan that proceeds without it.