// #1873 (redact6): an organization refusal names the field, the file or the root by label, // 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 { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { checkCredentialRef, parseCredentialRef, storePath } from '../src/credentials.mjs'; import { OrgError, orgLock, quietMessage, readRegistry, withRegistryWriter } from '../src/org-registry.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, { mode: 0o700 }); return { root, dir }; } function clean(text, root) { assert.ok(!text.includes(root), text); assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/); } test('a credential problem names the field and the root by label, never the token file or the root', t => { const { root, dir } = scratch(t); const file = join(dir, 'gitea'); writeFileSync(file, 'token', { mode: 0o644 }); const ref = { service: 'gitea', file, rotateBy: '2999-01-01' }; const { problems } = checkCredentialRef(ref, { forbiddenRoots: [{ path: dir, label: 'the data root' }, root], where: 'roles.coder.credentials.gitea' }); assert.deepEqual(problems, [ 'roles.coder.credentials.gitea file: mode 644 gives group or other access; use 600', 'roles.coder.credentials.gitea file: inside the data root; token files live outside the repository and dataRoot', 'roles.coder.credentials.gitea file: inside a forbidden root; token files live outside the repository and dataRoot', ]); for (const p of problems) clean(p, root); const missing = checkCredentialRef({ service: 'gitea', file: join(dir, 'absent'), rotateBy: '2999-01-01' }); assert.deepEqual(missing.problems, ['gitea file: not found']); const store = checkCredentialRef({ service: 'gitea', store: true, file: join(dir, 'absent'), rotateBy: '2999-01-01' }, { where: 'roles.coder.credentials.gitea' }); assert.deepEqual(store.warnings, ['roles.coder.credentials.gitea store file: not issued']); }); test('credential-not-yours names the field, not the file', t => { const { root, dir } = scratch(t); const store = { configDir: dir, business: 'acme', agent: 'coder' }; const other = storePath({ ...store, agent: 'pm' }, 'gitea'); assert.throws(() => parseCredentialRef({ file: other, rotateBy: '2999-01-01' }, 'gitea', 'roles.coder.credentials.gitea', { store }), error => { assert.match(error.message, /^roles\.coder\.credentials\.gitea\.file credential-not-yours: the file is in the agents' store/); clean(error.message, root); return true; }); }); test('an OrgError keeps a document field but drops a host path', t => { const { dir } = scratch(t); assert.equal(new OrgError('org-mismatch', 'unsafe file', join(dir, 'org.json')).path, undefined); assert.equal(new OrgError('org-invalid', 'bad root', '$.projects.alpha.root').path, '$.projects.alpha.root'); const error = Object.assign(new Error(`ENOENT: no such file or directory, open '${join(dir, 'x')}'`), { code: 'ENOENT', syscall: 'open' }); assert.equal(quietMessage(error, 'the registry file'), 'cannot open the registry file (ENOENT)'); assert.equal(quietMessage(Object.assign(new Error('x'), { code: 'ENOENT', syscall: `spawn ${join(dir, 'git')}` }), 'git'), 'cannot spawn git (ENOENT)'); }); test('an unreadable registry names the registry file and the error code, never its path', async t => { const { root, dir } = scratch(t); writeFileSync(join(dir, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(dir, 'real.json'), join(dir, 'org-runtime.json')); await assert.rejects(readRegistry({ configDir: dir }), error => { assert.ok(error instanceof OrgError, error.stack); assert.equal(error.code, 'org-mismatch'); assert.equal(error.message, 'invalid registry: cannot open registry file (ELOOP)'); assert.equal(error.path, undefined); clean(error.message, root); clean(JSON.stringify(error), root); return true; }); }); test('an unreadable recovery fence names the fence in the config directory, never its path', async t => { const { root, dir } = scratch(t); writeFileSync(join(dir, 'org-lifecycle-acme.lock.clear'), '{}', { mode: 0o600 }); chmodSync(join(dir, 'org-lifecycle-acme.lock.clear'), 0o644); await assert.rejects(orgLock({ configDir: dir }, 'acme', { clear: true }), error => { assert.ok(error instanceof OrgError, error.stack); assert.equal(error.code, 'org-locked'); assert.equal(error.message, 'unreadable recovery fence org-lifecycle-acme.lock.clear in the config directory; remove it by hand'); assert.equal(error.path, undefined); clean(error.message, root); return true; }); }); // Review round (#1873): the registry's own filesystem calls name the directory or file by label. test('a missing config directory names it by label and code, never its path, and keeps the system code', async t => { const { root, dir } = scratch(t); await assert.rejects(readRegistry({ configDir: join(dir, 'absent') }), error => { assert.equal(error.code, 'ENOENT'); assert.equal(error.message, 'cannot lstat the config directory (ENOENT)'); clean(error.message, root); return true; }); }); test('the registry writer names the registry file by label when it cannot open it, never its path', t => { const { root, dir } = scratch(t); writeFileSync(join(dir, 'real'), '{}', { mode: 0o600 }); symlinkSync(join(dir, 'real'), join(dir, 'org-runtime.json')); assert.throws(() => withRegistryWriter(dir, () => {}), error => { assert.equal(error.code, 'ELOOP'); assert.equal(error.message, 'cannot open the registry file (ELOOP)'); clean(error.message, root); return true; }); });