diff --git a/packages/bus/src/copy-grant.mjs b/packages/bus/src/copy-grant.mjs index 7bcded905..2203f1940 100644 --- a/packages/bus/src/copy-grant.mjs +++ b/packages/bus/src/copy-grant.mjs @@ -17,13 +17,15 @@ // receipt land. // Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply // or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a -// new verified issue. Nothing here logs, and no result, receipt or refusal holds the token, the +// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the // slot's path, a request or anything derived from the token. +import { lstatSync, readdirSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; import { BusError } from './broker.mjs'; -import { Credentials, assertNotHeld, day } from './credentials.mjs'; +import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs'; import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs'; import { scanOrganizations } from './definition-scan.mjs'; -import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES } from '../../organization/src/vocabulary.mjs'; +import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs'; import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs'; const deny = (code, reason) => { @@ -101,8 +103,9 @@ export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, t if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false) deny('credential-identity-refused', login); // Every repository the account can reach: owned, collaborator and team access. One unrelated - // repository, an admin permission, or push where the binding approved read refuses. - const write = levels.repository === 'write'; + // repository, an admin permission, or push where the binding approved read refuses. Push is the + // account's right, so it is held to what the binding approved, not to the token's own level. + const write = tokenLevels(approved)?.repository === 'write'; const seen = new Map(); let total = null; for (let page = 1; ; page++) { @@ -194,3 +197,37 @@ export function copyGrant({ credentials, writer, copies = null, organizations = } }; } + +// Security review P2: at start, before Credentials loads and before any issue can run, each staged +// file of a copy's service whose binding holds no grant for that service goes, and so does any +// interrupted write's temp file. An issue killed after its stage leaves the record pending and the +// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file +// only keeps a secret nothing will use. `documents` is [business, validated organization document] +// for each loaded business (null when unreadable: nothing of it is touched). Only names the document +// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names. +// A store dir that isn't private refuses credential-file, as Credentials would. Returns +// [{ instance, service }], never a path. +export function sweepCopySlots(storeRoot, documents, references = {}) { + const removed = []; + if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed; + for (const [business, doc] of documents) { + if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue; + const dir = join(storeRoot, business); + if (!privateDir(dir, { missing: true })) continue; + for (const [copy, record] of Object.entries(doc.copyBindings)) { + if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue; + const copyDir = join(dir, copy); + if (!privateDir(copyDir, { missing: true })) continue; + const instance = business + '/' + copy; + for (const name of readdirSync(copyDir)) { + const service = name.endsWith('.tmp') ? name.slice(0, -4) : name; + if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue; + if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue; + if (lstatSync(join(copyDir, name)).isDirectory()) continue; + unlinkSync(join(copyDir, name)); + removed.push({ instance, service }); + } + } + } + return removed; +} diff --git a/packages/bus/src/credentials.mjs b/packages/bus/src/credentials.mjs index 5bd1709b2..bf119d35c 100644 --- a/packages/bus/src/credentials.mjs +++ b/packages/bus/src/credentials.mjs @@ -490,7 +490,7 @@ export class Credentials { } // The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename // over the slot, fsync the dir. Returns the identity of the file it wrote. - #writeSlot(instance, service, text) { + #writeSlot(instance, service, text, { undo = false } = {}) { const file = this.#storePath(instance, service); const agentDir = dirname(file); mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 }); @@ -505,22 +505,43 @@ export class Credentials { } catch (e) { if (e.code !== 'ENOENT') deny('credential-file'); } + // A write that fails leaves no token behind: the temp file this call made goes, and with `undo` + // (stageCopy) so does the slot it renamed into place. let fd, identity; try { - fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); - writeSync(fd, text); - fsyncSync(fd); - const st = fstatSync(fd); - identity = { dev: st.dev, ino: st.ino }; - } finally { - if (fd !== undefined) closeSync(fd); + try { + fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + const st = fstatSync(fd); + identity = { dev: st.dev, ino: st.ino }; + writeSync(fd, text); + fsyncSync(fd); + } finally { + if (fd !== undefined) closeSync(fd); + } + renameSync(tmp, file); + } catch (e) { + if (identity) { + try { + const st = lstatSync(tmp); + if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp); + } catch {} + } + throw e; } - renameSync(tmp, file); - const dirFd = openSync(agentDir, constants.O_RDONLY); try { - fsyncSync(dirFd); - } finally { - closeSync(dirFd); + const dirFd = openSync(agentDir, constants.O_RDONLY); + try { + fsyncSync(dirFd); + } finally { + closeSync(dirFd); + } + } catch (e) { + if (undo) { + try { + this.unlinkCopy(instance, service, identity); + } catch {} + } + throw e; } return identity; } @@ -534,7 +555,7 @@ export class Credentials { stageCopy(instance, service, token) { if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference'); const value = Credentials.#format(service, token); - return this.#writeSlot(instance, service, value.token + '\n'); + return this.#writeSlot(instance, service, value.token + '\n', { undo: true }); } // The staged token, read back with the store's checks plus one link only and the exact identity the // stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file. diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 7a8388122..5a894586b 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -46,7 +46,7 @@ import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs'; import { getProjectRemote } from '../../organization/src/project-remotes.mjs'; import { credentialVerbs } from './credential-verbs.mjs'; import { loadWithCopies } from '../../organization/src/agent-copies.mjs'; -import { copyGrant } from './copy-grant.mjs'; +import { copyGrant, sweepCopySlots } from './copy-grant.mjs'; import { loadRole } from '../../organization/src/role.mjs'; import { resolveInstance } from '../../organization/src/resolve.mjs'; import { canonicalJson } from '../../organization/src/util.mjs'; @@ -404,6 +404,20 @@ export async function startBroker({ // Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to // gitea, and `held` names the files memory must not load until reconcile or a revoke. const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] }; + // #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds + // no grant for it, goes before Credentials loads and before any issue can run. + if (storeRoot) { + const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }); + const documents = Object.keys(businesses).map((id) => { + try { + return [id, reader.read(id).document]; + } catch { + return [id, null]; + } + }); + for (const entry of sweepCopySlots(storeRoot, documents, references)) + process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`); + } // #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials // refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when // any reference names the store. A root that exists with loose modes or as a symlink still refuses. diff --git a/packages/bus/tests/copy-grant.test.mjs b/packages/bus/tests/copy-grant.test.mjs index 25163f060..be1623134 100644 --- a/packages/bus/tests/copy-grant.test.mjs +++ b/packages/bus/tests/copy-grant.test.mjs @@ -12,7 +12,7 @@ import { Broker, BusError } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; import { Credentials } from '../src/credentials.mjs'; import { REFUSALS } from '../src/refusals.mjs'; -import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS } from '../src/copy-grant.mjs'; +import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs'; import { fakeGiteaApi } from './fake-gitea-api.mjs'; import { hireDecisions } from '../../organization/src/hire-decisions.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs'; @@ -146,6 +146,60 @@ test('CB2 read is narrower than write within a category; categories are independ assert.equal(f.rec().state, 'bound'); }); +test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => { + const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } }; + await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' }); + const write = { approved: ['write:repository', 'read:user'] }; + assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' }); + const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } }; + assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' }); + await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' }); +}); + +test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => { + const f = await fixture(t); + const tok = f.token(f.bot()); + const rev = f.snapshot.copies.rev; + const cases = [ + [{ ...f.snapshot, copies: {} }, 'copy-unavailable'], + [{ ...f.snapshot, org: 'other' }, 'copy-unavailable'], + [{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'], + [{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'], + [{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'], + [{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'], + ]; + const before = kept(f), sent = f.api.requests.length; + for (const [copies, code] of cases) { + f.opts.copies = copies; + await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org)); + } + f.opts.copies = null; + // No record for the name, or a name the organization's own roles hold. + await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' }); + f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } }; + const { document } = f.writer.read('demo'); + const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) }; + await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' }); + f.opts.copies = null; + assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]); + await f.issueAs(f.human, { token: tok.sha1 }); + assert.equal(f.rec().state, 'bound'); +}); + +test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => { + const root = mkdtempSync(join(tmpdir(), 'cb2-ref-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const storeRoot = join(root, 'agents'); + mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700); + const file = join(storeRoot, 'demo', 'rev', 'gitea'); + writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 }); + const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' }); + assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' }); + assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n'); + assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' }); + assert.equal(present(join(storeRoot, 'demo', 'other')), false); +}); + test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => { const vik = { tasks: ['read_one'] }; const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } }); @@ -263,9 +317,13 @@ test('CB2 a foreign account, an administrator or another copy\'s token is identi test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => { const f = await fixture(t); const u = f.bot(); - await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' }); + // A malformed token refuses before anything is staged or sent (build review 2): the bound record is + // unchanged, nothing reached Gitea and no slot exists. + const before = kept(f), sent = f.api.requests.length; await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' }); await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' }); + assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]); + await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' }); const tok = f.token(u); for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) { const undo = f.api.rule((e) => e.path === '/api/v1/token', answer); @@ -373,13 +431,16 @@ test('CB2 crash points leave the record pending and the slot removed; a new veri delete f.hooks[hook]; assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook); } - // A hard crash after the stage: the record is pending and the staged slot stays. Nothing resolves - // it; only a fresh verified issue binds. + // A hard crash after the stage (security review P2): the record is pending and the staged token is + // on disk. The next start's sweep removes it before anything loads; the record stays pending and + // only a fresh verified issue binds. const r = f.rec(); const { revision } = f.writer.read('demo'); f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true }); f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1); assert.equal(f.rec().state, 'pending'); + assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]); + assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]); await f.issueAs(f.human, { token: tok.sha1 }); assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]); // A lost reply: the commit landed but the caller never heard. Issuing again re-verifies. @@ -387,6 +448,49 @@ test('CB2 crash points leave the record pending and the slot removed; a new veri assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]); }); +test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => { + const f = await fixture(t); + const u = f.bot(); + const tok = f.token(u); + await f.issueAs(f.human, { token: tok.sha1 }); + assert.equal(f.rec().state, 'bound'); + const doc = () => [['demo', f.writer.read('demo').document]]; + const copyDir = join(f.storeRoot, 'demo', 'rev'); + // A granted slot stays; an interrupted write's temp file goes; a name that is no service stays. + writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 }); + writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 }); + assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]); + assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]); + // An ungranted service of a bound copy goes; a slot a loaded reference names stays. + writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 }); + assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []); + assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]); + // A role's own slots and an unreadable organization are never touched. + const roleDir = join(f.storeRoot, 'demo', 'chief'); + mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 }); + const edited = { ...f.writer.read('demo').document }; + edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } }; + assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []); + assert.equal(present(join(roleDir, 'gitea')), true); + writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 }); + assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []); + assert.equal(present(join(copyDir, 'gitea.tmp')), true); + // No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would. + assert.deepEqual(sweepCopySlots(null, doc(), {}), []); + assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []); + chmodSync(copyDir, 0o755); + assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/); + chmodSync(copyDir, 0o700); +}); + +test('CB2 a stage write that fails leaves no staged token behind', async (t) => { + const f = await fixture(t); + // The slot path is a non-empty directory: the rename fails after the temp file holds the token. + mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 }); + assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40))); + assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']); +}); + test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => { const f = await fixture(t); const u = f.bot(); diff --git a/packages/bus/tests/credential-verbs.test.mjs b/packages/bus/tests/credential-verbs.test.mjs index 789fdf3f3..8ca0413e7 100644 --- a/packages/bus/tests/credential-verbs.test.mjs +++ b/packages/bus/tests/credential-verbs.test.mjs @@ -4,13 +4,15 @@ // scratch root only. import test from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { startBroker } from '../src/runtime.mjs'; import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs'; import { Store } from '../src/store.mjs'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; +import { definitionPin } from '../../organization/src/copy-bindings.mjs'; +import { readTemplate } from '../../organization/src/definitions.mjs'; const TOKEN = 'fake_issued_token_0123456789'; const ROTATED = 'fake_rotated_token_9876543210'; @@ -18,16 +20,18 @@ const OTHER = 'fake_other_token_5555555555'; const DATE = '2099-01-01'; const role = { authority: { withinRole: ['message.send'], crossRole: [] } }; -async function setup(t, { storeRoot = true } = {}) { +async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-')); t.after(() => rmSync(root, { recursive: true, force: true })); const rolesDir = rolesCopy(root); const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } }; + edit(doc, rolesDir); doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE }; writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 }); const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service); + before(store); // The bus's resolved business carries the same references; a store one names its derived file. const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, @@ -187,3 +191,26 @@ test('#1886: a store root that exists with loose modes still refuses start', asy }; await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/); }); + +test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => { + // A copy binding left pending by an issue killed after its stage, its token and a temp file on disk. + const pending = (doc, rolesDir) => { + doc.copyBindings = { rev: { + copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null }, + definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending', + services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } }, + decision: '11111111-1111-4111-8111-111111111111' } }; + }; + const staged = (store) => { + mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 }); + chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700); + for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 }); + mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 }); + writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 }); + }; + const c = await setup(t, { edit: pending, before: staged }); + assert.equal(c.file().copyBindings.rev.state, 'pending'); + assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []); + // A role's own store slot is the store's, not the sweep's. + assert.equal(existsSync(c.store('coder', 'gitea')), true); +});