Files
stack/packages/discord/tests/path-redaction-labels.test.mjs
jason.woltje d9023e28db fix(discord): check-then-read gaps name the file by label, never the path (#1873)
A token file, context file, tool root, data root or setspark key file
that changes between its check and its read failed with a native error
whose message carried the host path. Each read now names the file by
label and code (quietRead): same error object, type and code, so exit
codes, refusal codes and check order are unchanged.
2026-11-11 09:50:14 -06:00

205 lines
11 KiB
JavaScript

// #1873 (redact6): a discord refusal names the file, field or binding, never a host path.
// The sentinel is a component of every scratch path, so a refusal that quotes a path
// quotes the sentinel too; each test also checks the scratch root and the label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { readApprovals } from '../src/approvals.mjs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DiscordError } from '../src/errors.mjs';
import { bindingDataDir, bindingPath, loadDataRoot, readToken, resolveContextFiles, resolveToolRoots, validateBinding } from '../src/binding.mjs';
import { ensureJournal, lockPath, ownerPath, readInboxIds, recover, requestStop, turnPath, unlock, writeTurn } from '../src/journal.mjs';
import { gitPull, loadGitConfig } from '../src/git.mjs';
import { loadSetsparkConfig, readKey } from '../src/setspark.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret);
mkdirSync(dir);
return { root, dir };
}
function labelled(label, root, Type = DiscordError) {
return error => {
assert.ok(error instanceof Type, error.stack);
assert.ok(error.message.includes(label), error.message);
assert.ok(!error.message.includes(root), error.message);
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
return true;
};
}
test('config not found names the config, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
});
test('token file refusals name the token file, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => readToken({ tokenFile: join(dir, 'token') }), labelled('token file not found', root));
const short = join(dir, 'short');
writeFileSync(short, 'x', { mode: 0o600 });
assert.throws(() => readToken({ tokenFile: short }), labelled('token file does not hold a bot token', root));
const open = join(dir, 'open');
writeFileSync(open, 'x'.repeat(30), { mode: 0o644 });
assert.throws(() => readToken({ tokenFile: open }), labelled('token file must be mode 0600, is 0644', root));
});
test('a context file refusal names its index in context.files', t => {
const { root, dir } = scratch(t);
const repo = join(dir, 'repo');
mkdirSync(repo);
assert.throws(() => resolveContextFiles({ context: { files: [join(dir, 'abs.md')] } }, repo), labelled('context file must be repository-relative: context.files[0]', root));
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('missing context file: context.files[0]', root));
});
test('a tool root refusal names the root, not its path', t => {
const { root, dir } = scratch(t);
const binding = { tools: { roots: [{ name: 'docs', path: join(dir, 'docs'), write: false }] } };
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs does not exist', root));
mkdirSync(join(dir, 'data', 'docs'), { recursive: true });
binding.tools.roots[0].path = join(dir, 'data', 'docs');
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs overlaps the data root', root));
});
test('git and setspark key file refusals name the field', t => {
const { root, dir } = scratch(t);
const git = { branch: 'main', identity: 'sage', tokenFile: join(dir, 'gitea'), author: 'Sage <[email protected]>' };
assert.throws(() => loadGitConfig(git, 'tools.roots[0].git'), labelled('tools.roots[0].git.tokenFile: not found', root, Error));
const setspark = { baseUrl: 'https://setspark.example', keyFile: join(dir, 'key'), principal: 'sage' };
assert.throws(() => loadSetsparkConfig(setspark), labelled('setspark.keyFile: not found', root, Error));
});
for (const [name, label] of [['bot', 'discord/bot under the data root'], [secret, "the binding's journal directory"]]) {
test(`journal refusals name ${label}`, t => {
const { root, dir } = scratch(t);
const journal = join(dir, name);
ensureJournal(journal);
requestStop(journal, 'operator');
assert.throws(() => recover(journal), labelled(`STOP is present in ${label}`, root));
rmSync(join(journal, 'STOP'));
mkdirSync(lockPath(journal));
writeFileSync(ownerPath(journal), secret, { mode: 0o600 });
assert.throws(() => unlock(journal), labelled(`Inspect run.lock/owner.json in ${label} by hand`, root));
writeTurn(journal, 't1', {});
assert.throws(() => writeTurn(journal, 't1', {}), labelled('turn record already exists: t1', root));
});
}
// Review round (#1873): keys, names, read errors and git output that could carry a host path.
test('an unknown key that is not a plain name is described, never echoed; a plain one is still quoted', t => {
const { root, dir } = scratch(t);
const doc = { [join(dir, 'host')]: 1 };
assert.throws(() => validateBinding(doc), labelled('unknown key that is not a plain name', root));
assert.throws(() => loadGitConfig(doc, 'tools.roots[0].git'), labelled('tools.roots[0].git: unknown key that is not a plain name', root, Error));
assert.throws(() => loadSetsparkConfig(doc, 'setspark'), labelled('setspark: unknown key that is not a plain name', root, Error));
assert.throws(() => validateBinding({ colour: 1 }), labelled('unknown key "colour"', root));
});
test('an invalid binding name or turn id states the constraint, never the value, and keeps its exit', t => {
const { root, dir } = scratch(t);
for (const f of [bindingPath, bindingDataDir]) {
assert.throws(() => f(dir, join(dir, 'bad')), e => labelled('invalid binding name (lowercase letters, digits, ., _ or -, at most 64)', root)(e) && e.exitCode === 4);
}
assert.throws(() => turnPath(dir, join(dir, 'bad')), e => labelled('invalid turn id (letters, digits, ., _ or -, at most 128)', root)(e) && e.exitCode === 1);
});
test('an unreadable journal or approvals file, or a missing repository, names the file and code, never the path', t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'approvals.jsonl'), '{}\n', { mode: 0o000 });
writeFileSync(join(dir, 'inbox.jsonl'), '{}\n', { mode: 0o000 });
assert.throws(() => readApprovals(dir), e => labelled('cannot read approvals.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES' && !(e instanceof DiscordError));
assert.throws(() => readInboxIds(dir), e => labelled('cannot read inbox.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES');
assert.throws(() => resolveContextFiles({ context: { files: ['x'] } }, join(dir, 'missing')), e => labelled('cannot read the repository (ENOENT)', root, Error)(e) && e.code === 'ENOENT');
});
test('a git refusal drops stderr lines that carry a host path and a spawn failure names only its code', t => {
const { root, dir } = scratch(t);
const rootCfg = { real: dir, name: 'docs', git: { branch: 'main', identity: 'sage', tokenFile: join(dir, 'never-read'), author: { name: 'Sage', email: '[email protected]' } } };
let calls = 0;
const spawn = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: cannot access ${join(dir, 'repo')}\n` };
assert.throws(() => gitPull(rootCfg, { spawn }), e => {
assert.equal(e.reason, 'that root has no git');
assert.equal(e.message, e.reason);
assert.ok(!e.detail, String(e.detail));
return labelled(e.reason, root, Error)(e);
});
assert.equal(calls, 2);
// A repo-relative line survives: the detail still says what git said.
calls = 0;
const kept = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: not a git repository: .git\n` };
assert.throws(() => gitPull(rootCfg, { spawn: kept }), e => e.detail === 'fatal: not a git repository: .git');
const failed = () => ({ error: Object.assign(new Error(`spawn ${join(dir, 'git')} ENOENT`), { code: 'ENOENT' }) });
assert.throws(() => gitPull(rootCfg, { spawn: failed }), e => labelled('git could not start (ENOENT)', root, Error)(e));
});
// Re-check (#1873): a file removed between its check and its read surfaces a native
// error. Each site names the file by label and code; the error's path never reaches the
// message. `race` swaps one node:fs function so the target goes away right before the
// real call, which then fails with a genuine ENOENT that names the path.
function race(t, fn, target, remove) {
const original = fs[fn];
const swapped = function (p, ...rest) {
if (String(p) === target) remove();
return original.call(this, p, ...rest);
};
if (original.native) swapped.native = original.native;
fs[fn] = swapped;
syncBuiltinESMExports();
t.after(() => {
fs[fn] = original;
syncBuiltinESMExports();
});
}
test('a token file removed between its check and its read names the token file', t => {
const { root, dir } = scratch(t);
const tokenFile = join(dir, 'token');
writeFileSync(tokenFile, 'x'.repeat(30), { mode: 0o600 });
race(t, 'readFileSync', tokenFile, () => rmSync(tokenFile));
assert.throws(() => readToken({ tokenFile }), labelled('cannot read token file (ENOENT)', root, Error));
});
test('a context file removed between its check and its realpath names its index', t => {
const { root, dir } = scratch(t);
const repo = join(dir, 'repo');
mkdirSync(repo);
writeFileSync(join(repo, 'ok.md'), 'ok');
writeFileSync(join(repo, `${secret}.md`), 'x');
const target = join(repo, `${secret}.md`);
race(t, 'realpathSync', target, () => rmSync(target));
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('cannot read context file context.files[1] (ENOENT)', root, Error));
});
test('a tool root removed between its check and its realpath names the root', t => {
const { root, dir } = scratch(t);
const docs = join(dir, 'docs');
mkdirSync(docs);
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
race(t, 'realpathSync', docs, () => rmSync(docs, { recursive: true }));
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('cannot read tool root docs (ENOENT)', root, Error));
});
test('a data root removed between its check and its realpath names the data root', t => {
const { root, dir } = scratch(t);
const docs = join(dir, 'docs');
mkdirSync(docs);
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
const data = join(dir, 'data');
mkdirSync(data);
race(t, 'realpathSync', data, () => rmSync(data, { recursive: true }));
assert.throws(() => resolveToolRoots(binding, { dataRoot: data }), labelled('cannot read the data root (ENOENT)', root, Error));
});
test('a setspark key file removed between its check and its read names keyFile', t => {
const { root, dir } = scratch(t);
const keyFile = join(dir, 'key');
writeFileSync(keyFile, 'ssk_' + 'a'.repeat(40), { mode: 0o600 });
const config = loadSetsparkConfig({ baseUrl: 'https://setspark.example', keyFile, principal: 'sage' });
race(t, 'readFileSync', keyFile, () => rmSync(keyFile));
assert.throws(() => readKey(config), labelled('cannot read keyFile (ENOENT)', root, Error));
});