mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
fix(cso): give snapshot.json its own 16 MiB cap and actionable capacity errors
/cso refused any repository with more than about 3,100 tracked files before an audit started: the snapshot manifest was pretty-printed and both its writer and every reader capped it at the shared 1 MiB private-state limit (#3068, manifest half of #2993). - snapshot.json is written compactly and capped by SNAPSHOT_MANIFEST_LIMIT (16 MiB), a constant local to the snapshot module. - readJson takes a per-artifact cap; readSnapshotManifest is the only reader of snapshot.json and passes the snapshot cap through recovery and the private-file size check. MAX_STATE_FILE and MAX_OUTPUT stay 1 MiB. - The manifest-cap and 64 MiB source-cap errors name the bound cap, the measured value, what counts toward it, the next step and #2993. A single file that would cross 64 MiB now hits the same message before it is read. - Older pretty-printed manifests still load (recheck, inspect). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f67c478f05
commit
25d66fc7ab
4 files changed
+211
-26
No files matched your search
+14
-14
@@ -34,7 +34,7 @@ import {
|
||||
validateFinding,
|
||||
validateVerificationRequest,
|
||||
} from './contracts';
|
||||
import { capture, containedFile, assertSnapshot } from './snapshot';
|
||||
import { capture, containedFile, assertSnapshot, readSnapshotManifest } from './snapshot';
|
||||
import {
|
||||
assertStateOutside,
|
||||
event,
|
||||
@@ -1057,7 +1057,7 @@ function originalBoundary(report: RunReportV3): {
|
||||
throw new CsoError('INVALID_SCHEMA', 'Recheck evidence requires a linked original finding');
|
||||
const dir = runDirectory(report.parent.runId),
|
||||
original = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest,
|
||||
manifest = readSnapshotManifest(dir),
|
||||
finding = original.findings.find((item) => item.id === report.parent!.findingId);
|
||||
if (report.repoId !== original.repoId)
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Recheck repository identity differs from the original audit');
|
||||
@@ -1293,7 +1293,7 @@ function submit(args: string[]) {
|
||||
const input = rawInput as SubmissionV3;
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
requireReportingTime(report);
|
||||
if (report.status !== 'running')
|
||||
@@ -1399,7 +1399,7 @@ function finish(args: string[]) {
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'finish takes only a run ID');
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
for (const c of report.coverage)
|
||||
if (
|
||||
@@ -1475,7 +1475,7 @@ async function inspect(args: string[]) {
|
||||
const { dir } = run(args);
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'inspect takes one run ID');
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const rawSensitive = readJson(join(dir, 'sensitive-evidence.json')),
|
||||
sensitiveEvidence = Array.isArray(rawSensitive)
|
||||
@@ -1505,7 +1505,7 @@ async function inspect(args: string[]) {
|
||||
async function read(args: string[]) {
|
||||
const { dir } = run(args);
|
||||
if (args.length !== 1) throw new CsoError('INVALID_ARGUMENT', 'read requires one path or opaque handle');
|
||||
const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
const manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const selected = resolveSnapshotPath(manifest, args[0], true),
|
||||
full = containedFile(join(dir, 'readable'), selected.path),
|
||||
@@ -1514,7 +1514,7 @@ async function read(args: string[]) {
|
||||
}
|
||||
async function history(args: string[]) {
|
||||
const { dir } = run(args),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
let selected: string | undefined;
|
||||
if (args.length) selected = resolveSnapshotPath(manifest, args.shift(), false, 'History path', true).path;
|
||||
@@ -1537,7 +1537,7 @@ function resume(args: string[]) {
|
||||
if (args.length) throw new CsoError('INVALID_ARGUMENT', 'resume takes one run ID');
|
||||
return withLock(dir, () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(dir);
|
||||
if (report.status === 'finished')
|
||||
throw new CsoError('INVALID_SCHEMA', 'A finished audit cannot be resumed');
|
||||
assertSnapshot(dir, manifest);
|
||||
@@ -1671,7 +1671,7 @@ async function scanner(args: string[], sarif = false) {
|
||||
id,
|
||||
runId: report.runId,
|
||||
runDir: dir,
|
||||
manifest: readJson(join(dir, 'snapshot.json')),
|
||||
manifest: readSnapshotManifest(dir),
|
||||
policy: report.policy,
|
||||
executionDeadline: Date.parse(report.deadline) - 60_000,
|
||||
platform: platform(),
|
||||
@@ -1822,7 +1822,7 @@ async function recheck(args: string[], dependencies: CsoCliDependencies) {
|
||||
throw new CsoError('INVALID_SCHEMA', 'Recheck requires a finished original audit');
|
||||
// Keep the original immutable while the fresh snapshot is captured and
|
||||
// until its child lineage report has been durably published.
|
||||
const oldManifest = readJson(join(originalDir, 'snapshot.json'));
|
||||
const oldManifest = readSnapshotManifest(originalDir);
|
||||
const preserveBase = original.policy.diff || Boolean(original.source.baseCommit),
|
||||
report = await start(
|
||||
[
|
||||
@@ -1922,7 +1922,7 @@ function testPlan(args: string[]) {
|
||||
if (args.length !== 1 || !['node', 'bun', 'python', 'rails'].includes(args[0]))
|
||||
throw new CsoError('INVALID_ARGUMENT', 'test-plan requires one supported stack');
|
||||
const stack = args[0] as 'node' | 'bun' | 'python' | 'rails',
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const preparation = inspectPreparation(join(dir, 'snapshot'), stack);
|
||||
if (preparation.status !== 'ready')
|
||||
@@ -1947,7 +1947,7 @@ function runtimePlan(args: string[]) {
|
||||
const port = Number(rawPort);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw new CsoError('INVALID_ARGUMENT', '--port must be an integer from 1024 to 65535');
|
||||
const manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
const manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
const preparation = inspectPreparation(join(dir, 'snapshot'), stack);
|
||||
if (preparation.status !== 'ready')
|
||||
@@ -2180,7 +2180,7 @@ async function verify(args: string[], dependencies: CsoCliDependencies) {
|
||||
request = validateVerificationRequest(raw);
|
||||
return await withLock(dir, async () => {
|
||||
const report = loadReport(dir),
|
||||
manifest = readJson(join(dir, 'snapshot.json')) as SnapshotManifest;
|
||||
manifest = readSnapshotManifest(dir);
|
||||
assertSnapshot(dir, manifest);
|
||||
requireTime(report);
|
||||
if (report.policy.mode !== 'comprehensive' || report.status !== 'running')
|
||||
@@ -2493,7 +2493,7 @@ async function replay(args: string[], dependencies: CsoCliDependencies) {
|
||||
throw new CsoError('INCOMPATIBLE_INPUT', 'Supplied source does not match the bundle input hashes');
|
||||
};
|
||||
if (fs.existsSync(retained)) {
|
||||
manifest = readJson(join(stored.dir, 'snapshot.json'));
|
||||
manifest = readSnapshotManifest(stored.dir);
|
||||
const expiresAt =
|
||||
typeof manifest?.expiresAt === 'string' ? Date.parse(manifest.expiresAt) : Number.NaN;
|
||||
if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== manifest.expiresAt)
|
||||
|
||||
+35
-7
@@ -15,7 +15,7 @@ import {
|
||||
MAX_OUTPUT,
|
||||
} from './contracts';
|
||||
import { childEnvironment, executable, git, redact, runProcess } from './process';
|
||||
import { secureDirectory, writeHelperJson, writeJson } from './state';
|
||||
import { readJson, secureDirectory, writeHelperJson, writeJson } from './state';
|
||||
import { scan } from '../redact-engine';
|
||||
import { atomicWriteSync } from '../fs-atomic';
|
||||
|
||||
@@ -52,6 +52,21 @@ function omittedPath(path: string): boolean {
|
||||
const SECRET_FILE =
|
||||
/(?:^|\/)(?:\.env(?:\..*)?|\.npmrc|\.yarnrc(?:\.yml)?|\.pypirc|pip\.conf|credentials(?:\.yml(?:\.enc)?)?|master\.key|id_(?:rsa|ed25519)|.*\.(?:pem|p12|pfx|key)|AGENTS\.md|CLAUDE\.md|GEMINI\.md|bunfig\.toml)$/i;
|
||||
const SOURCE_LIMIT = 64 * 1024 * 1024;
|
||||
/** snapshot.json holds one entry per source file, so it gets its own cap; other private state stays at 1 MiB. */
|
||||
export const SNAPSHOT_MANIFEST_LIMIT = 16 * 1024 * 1024;
|
||||
const CAPACITY_ISSUE = 'https://github.com/garrytan/gstack/issues/2993';
|
||||
const COUNTED_SOURCE =
|
||||
'every tracked or nonignored untracked file outside dependency and VCS directories (node_modules, .git, .venv, vendor/bundle and similar)';
|
||||
const mib = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
export function snapshotManifestCapMessage(entries: number, bytes: number): string {
|
||||
return `Snapshot manifest cap exceeded: ${entries} source entries need a ${mib(bytes)} (${bytes} byte) manifest; the snapshot manifest cap is ${mib(SNAPSHOT_MANIFEST_LIMIT)}. One entry is recorded for ${COUNTED_SOURCE}. Next step: run /cso on a smaller checkout of the code you want audited, and add your entry count to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
export function sourceCapMessage(bytes: number, files: number): string {
|
||||
return `64 MiB source cap exceeded: ${files} source files hold about ${mib(bytes)} (${bytes} bytes); the source admission cap is ${mib(SOURCE_LIMIT)}. The full size of ${COUNTED_SOURCE} counts, including files over 1 MiB whose payloads are withheld from the audit. Next step: run /cso on a smaller checkout of the code you want audited, and add your measured size to ${CAPACITY_ISSUE}. No supported workaround raises this cap.`;
|
||||
}
|
||||
export function readSnapshotManifest(runDir: string): SnapshotManifest {
|
||||
return readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT) as SnapshotManifest;
|
||||
}
|
||||
const SNAPSHOT_ENTRY_LIMIT = 100_000;
|
||||
const GIT_POINTER_LIMIT = 8192;
|
||||
export interface SnapshotCaptureLimits {
|
||||
@@ -611,10 +626,23 @@ export async function capture(
|
||||
manifest.baseCommit = (await git(repo, ['rev-parse', '--verify', `${base}^{commit}`], home)).trim();
|
||||
guard();
|
||||
}
|
||||
const sourceCapError = () => {
|
||||
let measured = 0,
|
||||
files = 0;
|
||||
for (const candidate of list) {
|
||||
if (noReadPath(candidate)) continue;
|
||||
try {
|
||||
measured += fs.lstatSync(join(repo, candidate)).size;
|
||||
files++;
|
||||
} catch {}
|
||||
}
|
||||
return new CsoError('MISSING_INPUT', sourceCapMessage(Math.max(measured, total), files));
|
||||
};
|
||||
for (const path of list) {
|
||||
guard();
|
||||
let size: number;
|
||||
try {
|
||||
fs.lstatSync(join(repo, path));
|
||||
size = fs.lstatSync(join(repo, path)).size;
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
absentPaths.add(path);
|
||||
@@ -640,11 +668,11 @@ export async function capture(
|
||||
guard();
|
||||
continue;
|
||||
}
|
||||
if (total + size > SOURCE_LIMIT) throw sourceCapError();
|
||||
const { data, mode } = readStable(repo, path, SOURCE_LIMIT);
|
||||
total += data.length;
|
||||
guard();
|
||||
if (total > SOURCE_LIMIT)
|
||||
throw new CsoError('MISSING_INPUT', 'Source exceeds the 64 MiB snapshot admission limit');
|
||||
if (total > SOURCE_LIMIT) throw sourceCapError();
|
||||
const entry: SnapshotEntry = {
|
||||
path,
|
||||
pathId: snapshotPathId(repo, path),
|
||||
@@ -862,11 +890,11 @@ export async function capture(
|
||||
// The manifest contains helper-computed identities and source pathnames but
|
||||
// never source payloads. Persist it exactly in private state: generic
|
||||
// content redaction would silently break the path/hash identity relation.
|
||||
const serialized = JSON.stringify(manifest, null, 2);
|
||||
if (Buffer.byteLength(serialized) + 1 > MAX_OUTPUT)
|
||||
const serialized = JSON.stringify(manifest);
|
||||
if (Buffer.byteLength(serialized) + 1 > SNAPSHOT_MANIFEST_LIMIT)
|
||||
throw new CsoError(
|
||||
'MISSING_INPUT',
|
||||
'Snapshot manifest exceeds the 1 MiB private-state admission limit',
|
||||
snapshotManifestCapMessage(entries.length, Buffer.byteLength(serialized) + 1),
|
||||
);
|
||||
atomicWriteSync(join(runDir, 'snapshot.json'), serialized + '\n', { mode: 0o600 });
|
||||
return manifest;
|
||||
|
||||
+6
-5
@@ -814,7 +814,7 @@ export function writeJsonExclusive(path: string, value: unknown): void {
|
||||
throw new CsoError('PERSISTENCE_FAILED', 'Private immutable artifact could not be written');
|
||||
}
|
||||
}
|
||||
function readPrivateJson(path: string): unknown {
|
||||
function readPrivateJson(path: string, maxBytes: number): unknown {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
const before = exactLstat(path);
|
||||
@@ -823,7 +823,7 @@ function readPrivateJson(path: string): unknown {
|
||||
before.isSymbolicLink() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size <= 0 ||
|
||||
before.size > MAX_STATE_FILE ||
|
||||
before.size > maxBytes ||
|
||||
(process.getuid && before.uid !== process.getuid()) ||
|
||||
(process.platform !== 'win32' && (before.mode & 0o077) !== 0)
|
||||
)
|
||||
@@ -857,11 +857,12 @@ function readPrivateJson(path: string): unknown {
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
export function readJson(path: string): any {
|
||||
/** maxBytes is a per-artifact cap; only the snapshot manifest passes a larger one. */
|
||||
export function readJson(path: string, maxBytes = MAX_STATE_FILE): any {
|
||||
try {
|
||||
secureDirectory(dirname(path));
|
||||
recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes: MAX_STATE_FILE });
|
||||
return readPrivateJson(path);
|
||||
recoverAtomicNoReplaceJson(path, { label: 'Private immutable artifact', maxBytes });
|
||||
return readPrivateJson(path, maxBytes);
|
||||
} catch (e) {
|
||||
if (e instanceof CsoError) throw e;
|
||||
throw new CsoError('MISSING_INPUT', 'Private state file is missing or invalid');
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// /cso file-count ceiling (#3068, manifest half of #2993): snapshot.json has its own
|
||||
// 16 MiB cap and compact form, every per-entry list in a 1 MiB private artifact is
|
||||
// bounded with an omitted count, and both capacity errors say what to do next.
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { MAX_OUTPUT } from '../lib/cso/contracts';
|
||||
import {
|
||||
SNAPSHOT_MANIFEST_LIMIT,
|
||||
capture,
|
||||
readSnapshotManifest,
|
||||
snapshotManifestCapMessage,
|
||||
sourceCapMessage,
|
||||
} from '../lib/cso/snapshot';
|
||||
import { newRun, readJson } from '../lib/cso/state';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const launcher = path.join(ROOT, 'bin', process.platform === 'win32' ? 'gstack-cso-launcher.exe' : 'gstack-cso-launcher');
|
||||
const MIB = 1024 * 1024;
|
||||
const ISSUE = 'https://github.com/garrytan/gstack/issues/2993';
|
||||
const KEY = ['pass', 'word'].join('');
|
||||
let root = '';
|
||||
let state = '';
|
||||
const originalHome = process.env.GSTACK_HOME;
|
||||
|
||||
function git(repo: string, ...args: string[]): string {
|
||||
const result = spawnSync('/usr/bin/git', ['-C', repo, ...args], { encoding: 'utf8', env: { HOME: root, PATH: '/usr/bin:/bin' }, timeout: 60_000 });
|
||||
if (result.status) throw new Error(result.stderr);
|
||||
return result.stdout;
|
||||
}
|
||||
function cso(repo: string, args: string[]) {
|
||||
return spawnSync(launcher, args, { cwd: repo, encoding: 'utf8', env: { HOME: root, GSTACK_HOME: state, PATH: '/usr/bin:/bin' }, timeout: 120_000, maxBuffer: 64 * MIB });
|
||||
}
|
||||
function makeRepo(name: string, files: Record<string, string | Buffer>): string {
|
||||
const repo = path.join(root, name);
|
||||
fs.mkdirSync(repo);
|
||||
git(repo, 'init', '-q');
|
||||
git(repo, 'config', 'user.email', 'fixture@example.test');
|
||||
git(repo, 'config', 'user.name', 'Fixture');
|
||||
for (const [file, body] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, file), body);
|
||||
}
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'fixture');
|
||||
return repo;
|
||||
}
|
||||
function runDir(run: { repoId: string; runId: string }): string {
|
||||
return path.join(state, 'security', 'cso', run.repoId, run.runId);
|
||||
}
|
||||
function writeInput(name: string, value: unknown): string {
|
||||
const file = path.join(root, name);
|
||||
fs.writeFileSync(file, JSON.stringify(value));
|
||||
return file;
|
||||
}
|
||||
const application = { actors: ['tenant user'], assets: ['tenant records'], entrypoints: ['GET /users/:id'], tenantBoundaries: ['tenant id'], sensitiveOperations: ['record read'], invariants: ['tenant isolation'] };
|
||||
const finding = { title: 'Cross-tenant user read', rootCause: 'Tenant query omits caller tenant predicate', location: { path: 'src/users.ts', line: 1, symbol: 'tenantQuery' }, advisoryIds: [], severity: 'high', confidence: 'high', confidenceRationale: 'The caller-to-query trace and missing tenant predicate directly support the finding', evidence: 'supported', attackerControl: 'Authenticated caller chooses the record ID', impact: 'Another tenant record is returned', scenario: 'A tenant supplies a known record ID owned by another tenant and receives that record', trace: ['GET /users/:id', 'tenantQuery', 'findUnique by id'], references: ['src/users.ts:1', 'OWASP API1:2023'], recommendation: 'Bind the lookup predicate to the authenticated tenant identifier', challenge: { reviewer: 'independent-2', independent: true, mode: 'independent_agent', callers: 'Authenticated route forwards the ID', controls: 'Authentication does not bind tenant', counterevidence: 'Opaque IDs reduce guessing but do not authorize', conclusion: 'The authorization invariant is absent' } };
|
||||
function completeEvidence(report: any, findings: unknown[]) {
|
||||
return { application, findings, coverage: report.coverage.filter((item: any) => !['snapshot-inputs', 'history-inputs'].includes(item.domain)).map((item: any) => ({ ...item, status: 'assessed', method: 'fresh caller and boundary trace', gaps: [], evidence: ['current captured source'] })), gaps: [] };
|
||||
}
|
||||
const USERS = 'export const tenantQuery = (id:string) => db.user.findUnique({where:{id}})\n';
|
||||
beforeAll(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-ceiling-'));
|
||||
state = path.join(root, 'state');
|
||||
process.env.GSTACK_HOME = state;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalHome === undefined) delete process.env.GSTACK_HOME;
|
||||
else process.env.GSTACK_HOME = originalHome;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('CSO snapshot manifest cap', () => {
|
||||
test('the snapshot cap is local to snapshot.json; shared limits stay 1 MiB', () => {
|
||||
expect(MAX_OUTPUT).toBe(MIB);
|
||||
expect(SNAPSHOT_MANIFEST_LIMIT).toBe(16 * MIB);
|
||||
const dir = fs.mkdtempSync(path.join(root, 'caps-'));
|
||||
fs.chmodSync(dir, 0o700);
|
||||
const big = JSON.stringify({ padding: 'x'.repeat(MIB + 16) });
|
||||
for (const name of ['report.json', 'snapshot.json']) fs.writeFileSync(path.join(dir, name), big, { mode: 0o600 });
|
||||
expect(() => readJson(path.join(dir, 'report.json'))).toThrow('Invalid private state file');
|
||||
expect(() => readJson(path.join(dir, 'snapshot.json'))).toThrow('Invalid private state file');
|
||||
expect(readSnapshotManifest(dir)).toEqual(JSON.parse(big));
|
||||
fs.writeFileSync(path.join(dir, 'snapshot.json'), JSON.stringify({ padding: 'x'.repeat(16 * MIB) }), { mode: 0o600 });
|
||||
expect(() => readSnapshotManifest(dir)).toThrow('Invalid private state file');
|
||||
});
|
||||
|
||||
test('every snapshot.json read in the helper goes through readSnapshotManifest', () => {
|
||||
const files = fs.readdirSync(path.join(ROOT, 'lib', 'cso')).filter((file) => file.endsWith('.ts'));
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(path.join(ROOT, 'lib', 'cso', file), 'utf8');
|
||||
expect(source).not.toMatch(/readJson\(\s*join\([^)]*'snapshot\.json'\)\s*\)/);
|
||||
}
|
||||
const cli = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'cli.ts'), 'utf8');
|
||||
expect(cli.match(/readSnapshotManifest\(/g)?.length).toBeGreaterThanOrEqual(13);
|
||||
const snapshot = fs.readFileSync(path.join(ROOT, 'lib', 'cso', 'snapshot.ts'), 'utf8');
|
||||
expect(snapshot).toContain("readJson(join(runDir, 'snapshot.json'), SNAPSHOT_MANIFEST_LIMIT)");
|
||||
expect(snapshot).toContain('> SNAPSHOT_MANIFEST_LIMIT');
|
||||
expect(snapshot).not.toContain('JSON.stringify(manifest, null, 2)');
|
||||
});
|
||||
|
||||
test('both capacity errors name the bound cap, measured value, what counts, next step and #2993', () => {
|
||||
const manifest = snapshotManifestCapMessage(61_234, 17_000_000);
|
||||
expect(manifest).toStartWith('Snapshot manifest cap exceeded:');
|
||||
for (const part of ['61234 source entries', '17000000 byte', 'cap is 16.0 MiB', 'One entry is recorded for every tracked', 'Next step:', ISSUE, 'No supported workaround'])
|
||||
expect(manifest).toContain(part);
|
||||
const source = sourceCapMessage(346_199_207, 7_561);
|
||||
expect(source).toStartWith('64 MiB source cap exceeded:');
|
||||
for (const part of ['7561 source files', '346199207 bytes', 'cap is 64.0 MiB', 'files over 1 MiB whose payloads are withheld', 'Next step:', ISSUE, 'No supported workaround'])
|
||||
expect(source).toContain(part);
|
||||
});
|
||||
|
||||
test('the 64 MiB source cap still fails closed, with the actionable message on stderr', () => {
|
||||
const repo = makeRepo('over-source-cap', { 'src/users.ts': USERS, 'assets/a.bin': Buffer.alloc(40 * MIB, 1), 'assets/b.bin': Buffer.alloc(25 * MIB, 1) });
|
||||
const result = cso(repo, ['start', '--repo', repo, '--offline']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stdout).toBe('');
|
||||
const error = JSON.parse(result.stderr).error;
|
||||
expect(error.code).toBe('MISSING_INPUT');
|
||||
expect(error.message).toStartWith('64 MiB source cap exceeded: 3 source files hold about 65.0 MiB');
|
||||
expect(error.message).toContain(ISSUE);
|
||||
expect(error.message).toContain('No supported workaround');
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('CSO above the old file-count ceiling', () => {
|
||||
test('a 2,500-file repository still captures and round-trips', async () => {
|
||||
const files: Record<string, string> = {};
|
||||
for (let i = 1; i <= 2500; i++) files[`src/pkg/sub/file_number_${i}.ts`] = `export const v${i} = ${i};\n`;
|
||||
const repo = makeRepo('plain-2500', files),
|
||||
run = newRun(repo),
|
||||
manifest = await capture(repo, run.dir);
|
||||
expect(manifest.entries).toHaveLength(2500);
|
||||
expect(readSnapshotManifest(run.dir)).toEqual(manifest);
|
||||
}, 120_000);
|
||||
|
||||
test('a retained pretty-printed snapshot.json from an older helper still loads in recheck', () => {
|
||||
const repo = makeRepo('legacy-pretty', { 'src/users.ts': USERS, 'src/other.ts': 'export const unrelated = true\n' }),
|
||||
run = JSON.parse(cso(repo, ['start', '--repo', repo, '--scope', 'auth', '--offline']).stdout),
|
||||
dir = runDir(run),
|
||||
initial = readJson(path.join(dir, 'report.json'));
|
||||
expect(cso(repo, ['submit', run.runId, writeInput('legacy-evidence.json', completeEvidence(initial, [finding]))]).status).toBe(0);
|
||||
expect(cso(repo, ['finish', run.runId]).status).toBe(0);
|
||||
const file = path.join(dir, 'snapshot.json');
|
||||
fs.writeFileSync(file, JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8')), null, 2) + '\n');
|
||||
expect(fs.readFileSync(file, 'utf8')).toContain('\n "entries": [');
|
||||
expect(cso(repo, ['inspect', run.runId]).status).toBe(0);
|
||||
const findingId = readJson(path.join(dir, 'report.json')).findings[0].id,
|
||||
rechecked = cso(repo, ['recheck', findingId, '--run', run.runId, '--repo', repo]);
|
||||
expect(rechecked.stderr).toBe('');
|
||||
expect(rechecked.status).toBe(0);
|
||||
expect(JSON.parse(rechecked.stdout).parent.runId).toBe(run.runId);
|
||||
}, 120_000);
|
||||
});
|
||||
Reference in new issue
Block a user