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.
205 lines
11 KiB
JavaScript
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));
|
|
});
|