enhance(#3908): the scanners distinguish an empty diff from one they could not compute (#3937)

* feat(#3908): the scanners distinguish an empty diff from one they could not compute

collect_files ended 2>/dev/null || true, which destroyed the evidence three ways: the redirect discarded git's diagnostic, the pipe replaced git's status with grep's, and || true forced success regardless. Four distinct conditions - an established-empty diff, a bad ref, no repository, and a repository with no commits - all reported clean, and a secret scanner reporting clean because git failed is indistinguishable from an all-clear to any gate consuming it.

git now runs separately from the filter so its status and diagnostic both survive. An established-empty diff exits NO_INPUT; a scope that could not be established exits UNAVAILABLE; the usage sites move off 2 to USAGE. || true is retained on the filter alone, where it is correct: a diff of only images is empty, not failed.

Codes are sourced from a generated shell fragment rather than written into three scripts, so a re-allocation cannot desync them, and a missing fragment fails loudly instead of falling back to literals. The security workflow is updated in the same change: without it, a docs-only PR would newly fail the job.

* fix(#3908): keep scanner stderr out of the file list, and drop try/finally from test bodies

Capturing git and find output with 2>&1 was right for the failure path but wrong for the success path: a warning emitted alongside a successful diff flowed into the file list and was treated as a filename. stderr is now captured separately, forwarded as a warning on success and as the diagnostic on failure, and never folded into the list.

Also converts the control tests' try/finally blocks to t.after(), which CONTRIBUTING bans inside a test body because it masks failures.

* chore(#3908): backfill changeset pr number

* docs(#3908): record the scanners' four-outcome exit contract

SECURITY.md is root-level, so the docs gate correctly held: a Changed fragment owes a file under docs/. The contract also belongs where the feature is described, as REQ-SCAN-INJ-05.

docs/FEATURES.md is GENERATED from per-feature fragments (#3840) - the first edit went into the generated file and gen-features --check caught it, which is the same edit-the-output drift this epic exists to close. The fragment is the source; FEATURES.md is regenerated.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-27 13:11:13 -04:00
committed by GitHub
parent 7f3119a29f
commit 1e67ec9737
31 changed files with 681 additions and 78 deletions

View File

@@ -55,16 +55,19 @@ function makeEntry(overrides) {
/**
* #3906 (ADR-3889 Phase 2): the generator now emits THREE artifacts — a
* primary (gsd-core/bin/lib), a secondary (scripts/lib), and the ambient
* `.d.cts` type declaration (src/exit-code-registry.d.cts). Every existing
* call site below only overrides the PRIMARY path via `--out`; without
* matching `--scripts-out`/`--dts-out` overrides, a `--write` here would
* clobber the real committed `scripts/lib/exit-code-registry.cjs` and
* `src/exit-code-registry.d.cts` — dangerous since test files in this repo
* run in parallel. Rather than touch every call site, this single seam
* derives co-located, per-call-unique secondary/dts paths from whatever
* `.d.cts` type declaration (src/exit-code-registry.d.cts). #3908 (Phase 4)
* added a FOURTH: the shell-sourceable fragment (gsd-core/bin/shared/
* exit-codes.sh). Every existing call site below only overrides the PRIMARY
* path via `--out`; without matching `--scripts-out`/`--dts-out`/`--sh-out`
* overrides, a `--write` here would clobber the real committed
* `scripts/lib/exit-code-registry.cjs`, `src/exit-code-registry.d.cts`, and
* `gsd-core/bin/shared/exit-codes.sh` — dangerous since test files in this
* repo run in parallel. Rather than touch every call site, this single seam
* derives co-located, per-call-unique secondary/dts/sh paths from whatever
* `--out` value the test already supplies, whenever the caller has not
* already supplied its own `--scripts-out`/`--dts-out`. Calls with no
* explicit `--out` (the "real committed set" checks) are left untouched.
* already supplied its own `--scripts-out`/`--dts-out`/`--sh-out`. Calls
* with no explicit `--out` (the "real committed set" checks) are left
* untouched.
*/
function ensureScriptsOut(args) {
const outIdx = args.indexOf('--out');
@@ -73,6 +76,7 @@ function ensureScriptsOut(args) {
const extra = [];
if (!args.includes('--scripts-out')) extra.push('--scripts-out', `${outValue}.secondary.cjs`);
if (!args.includes('--dts-out')) extra.push('--dts-out', `${outValue}.d.cts`);
if (!args.includes('--sh-out')) extra.push('--sh-out', `${outValue}.sh`);
return extra.length === 0 ? args : [...args, ...extra];
}

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -46,6 +46,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -80,6 +80,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -45,6 +45,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -45,6 +45,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -81,6 +81,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -11,6 +11,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -44,6 +44,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -115,6 +115,7 @@
"gsd-core/bin/shared/config-defaults.manifest.json",
"gsd-core/bin/shared/config-schema.manifest.json",
"gsd-core/bin/shared/exit-codes.json",
"gsd-core/bin/shared/exit-codes.sh",
"gsd-core/bin/shared/model-catalog.json",
"gsd-core/bin/shared/runtime-aliases.manifest.json",
"gsd-core/bin/verify-reapply-patches.cjs",

View File

@@ -32,14 +32,17 @@
// Migrating these to a parsed IR would add ceremony without changing
// what is verified — the strings ARE the typed surface.
const { describe, test } = require('node:test');
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { cleanup } = require('./helpers.cjs');
const { cleanup, createTempGitProject } = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const { gitOrThrow, GIT_FIXTURE_TIMEOUT_MS } = require('./helpers/git-fixture.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const PROJECT_ROOT = path.join(__dirname, '..');
const SCRIPTS = {
@@ -47,6 +50,10 @@ const SCRIPTS = {
base64: path.join(PROJECT_ROOT, 'scripts', 'base64-scan.sh'),
secret: path.join(PROJECT_ROOT, 'scripts', 'secret-scan.sh'),
};
// ADR-3889 (#3908): the generated exit-code registry — codes are resolved
// via exitCodeFor(), never hardcoded, so this suite stays correct if the
// registry's integers ever change.
const { exitCodeFor } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
// Helper: create a temp file with given content, run scanner, return { status, stdout, stderr }
const IS_WINDOWS = process.platform === 'win32';
@@ -189,7 +196,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.injection, [], {
encoding: 'utf-8',
@@ -198,7 +205,7 @@ describe('prompt-injection-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
});
@@ -280,7 +287,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.base64, [], {
encoding: 'utf-8',
@@ -289,7 +296,7 @@ describe('base64-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
@@ -492,7 +499,7 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
assert.equal(result.status, 0);
});
test('exits 2 on missing arguments', () => {
test('exits USAGE on missing arguments', () => {
try {
execFileSync(SCRIPTS.secret, [], {
encoding: 'utf-8',
@@ -501,7 +508,240 @@ describe('secret-scan.sh', { skip: IS_WINDOWS }, () => {
});
assert.fail('Should have exited non-zero');
} catch (err) {
assert.equal(err.status, 2);
assert.equal(err.status, exitCodeFor('USAGE'));
}
});
});
// ─── Exit-Code Contract (ADR-3889 Phase 4, #3908) ──────────────────────────
//
// Drives the real scripts through tests/helpers/process-seam.cjs's `runHook`
// (never a hand-rolled spawnSync — a review blocker on P3). Every repo
// fixture is a throwaway temp git repo built via
// createTempGitProject/gitOrThrow; nothing here depends on, or mutates, this
// repo's own git state, since test files in this suite run in parallel.
//
// The "shared" describes assert the SAME code across all three scanners for
// input classes that collapse identically regardless of scanner-specific
// extension filtering (a bad ref, no repo, no commits, an established-empty
// diff, an all-images diff, and every usage error). The "controls" describe
// is load-bearing: without a "files changed, no findings" case per scanner,
// an implementation that returns UNAVAILABLE unconditionally would satisfy
// every assertion above it.
describe('scanner exit-code contract', { skip: IS_WINDOWS }, () => {
const SCANNERS = [
['secret-scan', SCRIPTS.secret],
['base64-scan', SCRIPTS.base64],
['prompt-injection-scan', SCRIPTS.injection],
];
function runScanner(scriptPath, args, opts = {}) {
return runHook(scriptPath, args, { interpreter: 'bash', timeoutMs: HOOK_FANOUT_TIMEOUT_MS, ...opts });
}
describe('shared exit-code classes (identical across all three scanners)', () => {
let repo;
before(() => { repo = createTempGitProject('gsd-scan-shared-'); });
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(`${name}: nonexistent --diff ref -> UNAVAILABLE, git diagnostic on stderr`, () => {
const result = runScanner(scriptPath, ['--diff', 'refs/heads/does-not-exist-xyz'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
assert.ok(result.stderr.length > 0, 'git\'s own diagnostic must survive on stderr');
});
test(`${name}: --file with a nonexistent path -> USAGE`, () => {
const result = runScanner(scriptPath, ['--file', path.join(repo, 'does-not-exist.md')], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: --dir with a nonexistent path -> USAGE`, () => {
const result = runScanner(scriptPath, ['--dir', path.join(repo, 'does-not-exist-dir')], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: unknown mode -> USAGE`, () => {
const result = runScanner(scriptPath, ['--bogus-mode'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
test(`${name}: no argv at all -> USAGE`, () => {
const result = runScanner(scriptPath, [], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('USAGE'));
});
}
});
describe('outside a git repository -> UNAVAILABLE', () => {
let nonRepoDir;
before(() => { nonRepoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-norepo-')); });
after(() => { cleanup(nonRepoDir); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: nonRepoDir });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('repo with no commits -> UNAVAILABLE', () => {
let emptyRepo;
before(() => {
emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-nocommit-'));
gitOrThrow(['init'], { cwd: emptyRepo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(emptyRepo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'origin/next'], { cwd: emptyRepo });
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('established-empty diff (base === HEAD) -> NO_INPUT', () => {
let repo;
before(() => {
repo = createTempGitProject('gsd-scan-emptydiff-');
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, exitCodeFor('NO_INPUT'));
});
}
});
describe('all-images diff -> NO_INPUT (not a failure, not UNAVAILABLE)', () => {
let repo;
before(() => {
repo = createTempGitProject('gsd-scan-images-');
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add image only'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
});
after(() => { cleanup(repo); });
for (const [name, scriptPath] of SCANNERS) {
test(name, () => {
const result = runScanner(scriptPath, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(
result.exitCode, exitCodeFor('NO_INPUT'),
`expected NO_INPUT — stdout: ${result.stdout} stderr: ${result.stderr}`,
);
assert.notEqual(result.exitCode, 1, `${name} must not report the all-images diff as a failure`);
});
}
});
// ── Controls ───────────────────────────────────────────────────────────
describe('controls: clean scan / findings scan / mixed diff still work', () => {
test('secret-scan: clean scan with a real file still exits 0', (t) => {
const repo = createTempGitProject('gsd-scan-clean-secret-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'code.txt'), 'clean text content\n');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add clean file'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
});
test('secret-scan: a diff WITH a real secret still reports findings (exit 1)', (t) => {
const repo = createTempGitProject('gsd-scan-findings-secret-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const key = ['AKIA', 'IOSFODNN7EXAMPLE'].join('');
fs.writeFileSync(path.join(repo, 'secret.txt'), `aws_key = "${key}"\n`);
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'add secret'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.secret, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 1, result.stdout + result.stderr);
assert.ok(result.stdout.includes('FAIL'));
});
test('prompt-injection-scan: mixed images+code diff scans the code file (exit 0, clean)', (t) => {
const repo = createTempGitProject('gsd-scan-mixed-');
t.after(() => cleanup(repo));
gitOrThrow(['branch', 'base-branch'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
fs.writeFileSync(path.join(repo, 'pic.png'), 'fake png bytes');
fs.writeFileSync(path.join(repo, 'clean.md'), '# Clean docs\n');
gitOrThrow(['add', '-A'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'mixed'], { cwd: repo, timeoutMs: GIT_FIXTURE_TIMEOUT_MS });
const result = runScanner(SCRIPTS.injection, ['--diff', 'base-branch'], { cwd: repo });
assert.equal(result.exitCode, 0, result.stdout + result.stderr);
});
test('--file / --dir / --stdin controls still scan and pass on clean content', (t) => {
const repo = createTempGitProject('gsd-scan-modes-');
t.after(() => cleanup(repo));
const cleanFile = path.join(repo, 'clean.md');
fs.writeFileSync(cleanFile, '# Clean docs\n');
assert.equal(runScanner(SCRIPTS.injection, ['--file', cleanFile]).exitCode, 0);
assert.equal(runScanner(SCRIPTS.injection, ['--dir', repo]).exitCode, 0);
const stdinResult = runScanner(SCRIPTS.injection, ['--stdin'], { input: '# clean\n' });
assert.equal(stdinResult.exitCode, 0);
});
});
describe('--dir unreadable -> UNAVAILABLE', () => {
let parent, locked;
before(() => {
parent = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-unreadable-'));
locked = path.join(parent, 'locked');
fs.mkdirSync(locked);
fs.chmodSync(locked, 0o000);
});
after(() => {
try { fs.chmodSync(locked, 0o755); } catch { /* best effort, for cleanup() below */ }
cleanup(parent);
});
for (const [name, scriptPath] of SCANNERS) {
test(name, (t) => {
// Root (and some CI/Docker images running as root) bypasses mode
// bits entirely — a bare `return` here would be a silent PASS, so
// this is an explicit t.skip() instead.
if (typeof process.getuid === 'function' && process.getuid() === 0) {
t.skip('running as root — mode bits do not restrict access');
return;
}
const result = runScanner(scriptPath, ['--dir', locked]);
assert.equal(result.exitCode, exitCodeFor('UNAVAILABLE'));
});
}
});
describe('missing exit-codes.sh -> loud non-zero, never 0', () => {
// Isolated copy of the scanner in a throwaway tree whose gsd-core/bin/
// shared/ directory has no exit-codes.sh — never touches the real
// committed file, so this is safe under parallel test-file execution.
function isolatedCopyWithNoRegistry(scriptPath) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scan-noregistry-'));
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true });
fs.mkdirSync(path.join(root, 'gsd-core', 'bin', 'shared'), { recursive: true });
const dest = path.join(root, 'scripts', path.basename(scriptPath));
fs.copyFileSync(scriptPath, dest);
fs.chmodSync(dest, 0o755);
return { root, dest };
}
for (const [name, scriptPath] of SCANNERS) {
test(name, (t) => {
const { root, dest } = isolatedCopyWithNoRegistry(scriptPath);
t.after(() => cleanup(root));
const result = runScanner(dest, ['--diff', 'origin/next']);
assert.ok(Number.isInteger(result.exitCode), `expected a numeric exit code, got ${result.exitCode}`);
assert.notEqual(result.exitCode, 0, 'a missing exit-code registry must never silently exit 0');
});
}
});
});