From 61ef385e4a43db5776690ae9e32fa175b52ce5c3 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 22 Oct 2026 13:56:54 -0500 Subject: [PATCH] Row 286 C2-03b: library push, per-seat credentials, the chief's fetch credential, askpass fd check (#1791, sage-2922/2925/2928, dewey-0860); unrun --- agents/ludwig/work/queue-286/BUILD.md | 49 ++++- packages/bus/src/broker.mjs | 30 +++ packages/bus/src/runtime.mjs | 18 +- .../bus/tests/library-credential.test.mjs | 172 +++++++++++++++ packages/library/README.md | 38 ++++ packages/library/bin/askpass | 14 +- packages/library/src/index.mjs | 2 +- packages/library/src/mirror.mjs | 6 +- packages/library/src/sync.mjs | 104 ++++++++- packages/library/tests/sync.test.mjs | 205 +++++++++++++++++- 10 files changed, 614 insertions(+), 24 deletions(-) create mode 100644 packages/bus/tests/library-credential.test.mjs diff --git a/agents/ludwig/work/queue-286/BUILD.md b/agents/ludwig/work/queue-286/BUILD.md index 1406d2941..ab04deb59 100644 --- a/agents/ludwig/work/queue-286/BUILD.md +++ b/agents/ludwig/work/queue-286/BUILD.md @@ -6,7 +6,7 @@ after r11 "agents never write story" in `agents/gosalyn/work/case2/DESIGN.md` at Base: `refactor` at 94c33a43c. The brief cites aca16404c, and lines have moved since: `GRANT_EVIDENCE` is `broker.mjs:253` now, not `:246`. -Status: plan only. No code and no node yet. Row 258 comes first in my order. +Status: code written, local and unrun (18:48Z on). No node until Sage's CLEAR, after row 258's run. ## What the code does today (94c33a43c) @@ -171,3 +171,50 @@ N3: `fetchCredential` reads the store, so it is the bus's choice function, not a (F40, Q226). The refusal's message names the fix: "add the chief of staff, or grant the library through C2-21". - N1: as above. + +## Build notes (code written; nothing run yet) + +Files changed: +- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`; + `humanCredential()`; `sync()` takes `credential`; `HUMAN_SLOT`, `CREDENTIAL_KINDS`. The `slot` opt + and `MOSAIC_LIBRARY_SLOT` keep their names and now name whichever file answers. +- `packages/library/src/index.mjs`: the new exports. +- `packages/library/bin/askpass`: Dewey's fd 3 check. +- `packages/library/src/mirror.mjs`: optional `credential` in the state key check. Nothing renamed. +- `packages/bus/src/broker.mjs`: `LIBRARY_FETCH_FIX` and `libraryFetchCredential(business)`. +- `packages/bus/src/runtime.mjs`: `syncOptions` passes the broker's choice to the verb and the timer. + The timer's held line adds `fix` for `library-fetch-no-grant`. +- `packages/library/README.md`: "Push and credentials (C2-03b)". +- Tests: `packages/library/tests/sync.test.mjs` (appended), `packages/bus/tests/library-credential.test.mjs` (new). + +Choices made without a ruling. Each is small and reversible: +- `humanCredential(context, { user, configDir })`: sage-2925 named `{user}`. The slot path needs the + config dir, and the library package doesn't know it, so the caller passes it. That's the same + source runtime's `slotDir` uses. +- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It + goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`. +- T7 stages revoke and fire as the state those verbs leave (row deleted plus `grant.revoked`; name + delisted with `roles.chief` kept, and a `fire.stopped` with no `fire.done`). The chief has no Gitea + grant path before C2-21, so the evidence and the row go in by SQL, as gitea-grant.test.mjs does for + decisions. The verbs' own effects are pinned in gitea-grant.test.mjs and fire-revokes-grants.test.mjs. + This departs from the brief's "through grants.revoke" and "through instance.fire". For Dewey. +- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file. +- The race fetch inside `push()` isn't serialized with the timer's sync. Two fetches of the same + ref race on git's ref lock, and the loser fails (`library-push-failed` or `library-fetch-failed`), + never a wrong ref. C2-02 owns serializing a write with sync. This is a bounded follow-up, not this row. +- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied, + grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count. + +Test map: +- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or + unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b. +- bus library-credential.test.mjs: T6, T7, T8, departure, T9 runtime (chief file and user via timer + and verb; revoked with slot 0000: no git, log `fix`, state `credential: null`). +- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines. + The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`. + +Mutants (each must die on a semantic assertion): +- M1: `+` before the push refspec in `syncCommand`. Expected to fail: the argv test, and T2 (the + forced push lands, so `raced` is never reported and her commit is gone). +- M2: drop `test "$1" = "$path"` from askpass. Expected to fail: T5b (the helper prints the checked + file's token). diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index d5e7f18fd..0a77a86bb 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -259,6 +259,9 @@ const grantName = (body) => [body?.action, body?.operation].some(n => typeof n = // Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or // actor is not evidence. export const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'"; +// C2-03b: what a library-fetch-no-grant refusal tells the human (sage-2928). A BusError carries only its +// code, so the timer's log line and the library README carry this text. +export const LIBRARY_FETCH_FIX = 'add the chief of staff, or grant the library through C2-21'; // r6 R4: the host record's identity, hashed at raise and at each later check. export const registrySha256 = ({ address, port, user, sudo, hostKeys }) => createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex'); @@ -1951,6 +1954,33 @@ export class Broker { // grant.host.enrolled or grant.failed died after its registry step: the key file and the record // go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way. // Neither runs late. + // C2-03b (#1791, design 7.2): the credential the library fetch runs with, the bus's choice and not a + // pure one: it reads the store. The chief is any roles entry of definition STAFF_TO; a fire keeps it + // there (dewey-0860 Q2). Gitea grant evidence for a chief ends the slot fallback, and only the one + // listed chief's live, unheld gitea_grants row answers. With no chief entry, any gitea grant evidence + // refuses: a departure from the brief's pre-C2-21 fallback, accepted in sage-2928. + libraryFetchCredential(business) { + const b = this.#business(business); + const chiefs = Object.entries(b.roles ?? {}).filter(([, r]) => r?.definition === STAFF_TO).map(([name]) => name); + const evidence = (agent) => + this.#store.get( + `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.method')='gitea' AND (? IS NULL OR json_extract(body,'$.agent')=?) LIMIT 1`, + business, + agent, + agent, + ); + const refused = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX }; + if (!chiefs.length) return evidence(null) ? refused : { kind: 'slot' }; + if (!chiefs.some((agent) => evidence(agent))) return { kind: 'slot' }; + const listed = chiefs.filter( + (agent) => !this.#firing(business, agent) && (!Array.isArray(b.launch?.instances) || b.launch.instances.includes(agent) || b.launch.by === agent), + ); + if (listed.length !== 1) return refused; + const [agent] = listed; + const live = this.#store.get('SELECT user FROM gitea_grants WHERE business=? AND agent=?', business, agent); + if (!live || !this.#giteaKeys || this.#giteaKeys.held(business, agent)) return refused; + return { kind: 'chief', agent, user: live.user }; + } reconcileGrants() { const ended = []; for (const business of Object.keys(this.#businesses)) { diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index d02d85158..5f8d289c1 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { Store } from './store.mjs'; import * as projectModule from '../../project/src/index.mjs'; import * as libraryModule from '../../library/src/index.mjs'; -import { Broker, BusError } from './broker.mjs'; +import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs'; import { Credentials } from './credentials.mjs'; import { serve } from './server.mjs'; import { launchSettings } from '../../business/src/launch-settings.mjs'; @@ -24,7 +24,7 @@ import { busBusiness } from './business.mjs'; import { webService } from './web.mjs'; import { hostExecutor, sweepRunDir } from './host-run.mjs'; import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs'; -import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs'; +import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs'; import { verifyPrincipal } from './principal.mjs'; import { loadAccess } from '../../business/src/access.mjs'; import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs'; @@ -223,9 +223,18 @@ export async function startBroker({ } // C2-03a: the slot is /secrets//library-gitea (design 10 step 4). Sync only // checks that it is there; the askpass helper reads it. + // C2-03b: the broker chooses. The chief's grant store file once a chief has Gitea grant evidence, + // the slot before that, or a refusal sync records. The answer carries no file to sync but this one. const slotDir = librarySync?.configDir ?? organization?.dir ?? null; + const fetchCredential = business => { + const choice = broker.libraryFetchCredential(business.id); + if (choice.kind === 'chief') + return { kind: 'chief', user: choice.user, file: storeRoot ? giteaReference(storeRoot, business.id, choice.agent).file : null }; + if (choice.kind === 'slot') return { kind: 'slot', file: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null }; + return { refused: choice.refused }; + }; const syncOptions = business => ({ - slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null, + credential: fetchCredential(business), ...(librarySync ? { command: librarySync.command } : {}), }); // Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart @@ -565,7 +574,8 @@ export async function startBroker({ for (const { business, project } of timed) { try { const { code } = await libraryModule.sync({ business, project, dataRoot }, syncOptions(business)); - if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code }); + if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code, + ...(code === 'library-fetch-no-grant' ? { fix: LIBRARY_FETCH_FIX } : {}) }); } catch (e) { if (e?.code === 'library-sync-busy') continue; syncLog({ event: 'library-sync-refused', business: business.id, project, diff --git a/packages/bus/tests/library-credential.test.mjs b/packages/bus/tests/library-credential.test.mjs new file mode 100644 index 000000000..750ce65d6 --- /dev/null +++ b/packages/bus/tests/library-credential.test.mjs @@ -0,0 +1,172 @@ +// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file +// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise. The +// chief has no Gitea grant path before C2-21, so its evidence and gitea_grants row go in by SQL as +// fixtures, as gitea-grant.test.mjs does for decisions. A revoke and a fire are staged as the state +// those verbs leave: gitea-grant.test.mjs and fire-revokes-grants.test.mjs pin that a revoke deletes +// the row and writes grant.revoked, and that a fire delists the name and keeps roles[name] (dewey-0860). +// Tokens are made up and only ever compared. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { Store } from '../src/store.mjs'; +import { Broker, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs'; +import { startBroker } from '../src/runtime.mjs'; +import { gitEnv } from '../../seat/src/checkout.mjs'; +import { mappingEntry, repo } from '../../library/tests/helpers.mjs'; +import * as library from '../../library/src/index.mjs'; +import { grantDecision } from './host-fixtures.mjs'; +import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs'; + +const AT = '2026-10-22T00:00:00.000Z'; +const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048'; +const CHIEF_TOKEN = 'fixture-token-e413-made-up-0049'; +const role = { authority: { withinRole: [], crossRole: [] } }; + +const makeBusiness = ({ chief = true, instances = null } = {}) => ({ + id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, + roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role } } : {}), coder: { definition: 'coder', ...role } }, + projects: { books: { ...mappingEntry(), user: 'books-bot' } }, + ...(instances ? { launch: { instances } } : {}), +}); + +// A Gitea grant for `agent` as row 236 leaves it: the decision, the user and grant rows, and +// grant.applied. `project` is the grant's target; the evidence check names no project. +function grant(store, agent, { user = agent + '-bot', project = 'books', row = true } = {}) { + const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent, target: { projects: [project] }, rotateBy: '2099-01-01' } }); + writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision); + writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent, grantDecision: decision, user, projects: [project] }, decision); + if (!row) return decision; + store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', 'acme', agent, user, 7, randomBytes(8).toString('hex'), decision); + store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)', + 'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision); + return decision; +} +// The state a revoke leaves: no gitea_grants row and grant.revoked. +function revoke(store, agent) { + store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent); + writeGrantEvent(store, AT, 'acme', { action: 'grant.revoked', method: 'gitea', agent }); + assert.equal(store.get('SELECT 1 FROM gitea_grants WHERE business=? AND agent=?', 'acme', agent), undefined); +} + +function world(t, options) { + const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root); + t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); + const business = makeBusiness(options); + const held = new Set(); + const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } }); + return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') }; +} + +const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX }; + +test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => { + const w = world(t); + assert.deepEqual(w.choose(), { kind: 'slot' }); + grant(w.store, 'coder'); + assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback'); + grant(w.store, 'chief', { user: 'chief-bot' }); + assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }); + assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED, + 'a bus with no grant store refuses rather than guess'); + assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' }); + assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21'); +}); + +test('C2-03b T7: a revoked, fired or held chief each refuse library-fetch-no-grant, and the fallback stays ended', t => { + const revoked = world(t); + grant(revoked.store, 'chief'); + revoke(revoked.store, 'chief'); + assert.deepEqual(revoked.choose(), REFUSED, 'revoked'); + + // A fire delists the name; roles[chief] stays, so its evidence still ends the fallback. + const fired = world(t, { instances: ['coder'] }); + grant(fired.store, 'chief'); + assert.ok(fired.business.roles.chief, 'roles[chief] remains after a fire'); + assert.deepEqual(fired.choose(), REFUSED, 'fired'); + fired.business.launch.instances.push('chief'); + assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again'); + fired.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)", + AT, 'acme', JSON.stringify({ action: 'fire.stopped' })); + assert.deepEqual(fired.choose(), REFUSED, 'a fire under way'); + + const held = world(t); + grant(held.store, 'chief'); + held.held.add('acme/chief'); + assert.deepEqual(held.choose(), REFUSED, 'held'); +}); + +test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse', t => { + const w = world(t); + grant(w.store, 'chief', { project: 'other-project', row: false }); + assert.deepEqual(w.choose(), REFUSED); + const two = world(t); + two.business.roles.second = { definition: 'chief-of-staff', ...role }; + grant(two.store, 'chief'); + assert.deepEqual(two.choose(), REFUSED, 'one chief only'); +}); + +test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => { + const w = world(t, { chief: false }); + assert.deepEqual(w.choose(), { kind: 'slot' }); + grant(w.store, 'coder'); + assert.deepEqual(w.choose(), REFUSED); +}); + +// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's +// store file, the slot, or a refusal that runs no git even with the slot at 0000. +test('C2-03b T9 runtime: the chief\'s store file and user, and a refusal that runs no git with the slot at 0000', { timeout: 30000 }, async t => { + const f = repo(t), root = f.root, dataRoot = join(root, 'data'); + mkdirSync(dataRoot, { mode: 0o700 }); + const rolesDir = rolesCopy(root); + const slot = join(root, 'secrets', 'acme', library.SLOT); + mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 }); + writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 }); + const chiefFile = join(root, 'secrets', 'agents', 'acme', 'chief', 'gitea'); + mkdirSync(join(root, 'secrets', 'agents', 'acme', 'chief'), { recursive: true, mode: 0o700 }); + writeFileSync(chiefFile, CHIEF_TOKEN + '\n', { mode: 0o600 }); + const seed = new Store(dataRoot); + grant(seed, 'chief', { user: 'chief-bot' }); + seed.close(); + const calls = [], lines = [], write = process.stderr.write; + const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; }; + process.stderr.write = function (chunk, ...rest) { + if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; } + return write.call(this, chunk, ...rest); + }; + t.after(() => { process.stderr.write = write; }); + const start = () => startBroker({ dataRoot, businesses: { acme: makeBusiness() }, organization: { dir: root, rolesDir, system: systemFor(root) }, + librarySync: { command, intervalMs: 50 } }); + const until = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await new Promise(r => setTimeout(r, 10)); }; + + let runtime = await start(); + await until(() => calls.length >= 1); + const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e); + await runtime.close(); + assert.ok(calls.length >= 2, 'the timer and the verb'); + assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'chief-bot'), 'the chief\'s file and user, never the slot'); + + // Revoked: the next start refuses before git, and the log names the fix. + const again = new Store(dataRoot); + revoke(again, 'chief'); + again.close(); + chmodSync(slot, 0o000); + calls.length = 0; lines.length = 0; + runtime = await start(); + await until(() => lines.length >= 1); + const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' }); + await runtime.close(); + assert.equal(calls.length, 0, 'no git with a refusal'); + assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up'); + assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)), + { event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX }); + for (const line of lines) assert.equal(line.includes(SLOT_TOKEN) || line.includes(CHIEF_TOKEN), false); + const mirror = library.mirrorPath({ business: makeBusiness(), project: 'books', dataRoot }); + const state = await library.readSyncState(mirror, { branch: 'main' }); + assert.equal(state.lastError.code, 'library-fetch-no-grant'); + assert.equal(state.credential, null); +}); diff --git a/packages/library/README.md b/packages/library/README.md index b7d0f579c..4c419b31d 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -180,6 +180,44 @@ and `library.status` shows the result once it ends. A sync that outlasts the 120 `request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs` passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it. +### Push and credentials (C2-03b) + +`push(context, { commit, credential, retried })` sends a full commit id, already in the mirror, to the +entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-options +:refs/heads/`, through the same prefix, env and helper as the fetch. The refspec has no +`+`, so the remote takes a fast-forward only. + +| git says | Answer | +|---|---| +| the push landed | `{ pushed: true }` | +| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` | +| the same, with `retried: true` | `stale-base` | +| anything else, `[remote rejected]` (a hook) included | `library-push-failed` | + +Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept. + +A credential is `{ kind, user, file }`, with `kind` one of `slot`, `chief`, `seat` or `human`, or the +bus's `{ refused: code }`. The helper reads only `file`. It opens the file on fd 3 and refuses unless +the open file and the path have the same device and inode, and the file is regular, the bus's uid's, +mode 0600 and at most 16384 bytes, as `readSecretFile` in `packages/bus/src/gitea-grant.mjs` checks a +grant store file. A FIFO swapped in after `test -f` blocks the open, the same residual +`readSecretFile` has. + +- A seat writes with its own Gitea user and grant store file, `///gitea`. +- `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and + `/secrets//library-human`. A missing login, or a slot that is missing, a link, + another uid's or not 0600, refuses `library-human-missing` before any git call. +- The fetch takes the broker's choice, `libraryFetchCredential(business)`. The chief is a `roles` + entry whose definition is `chief-of-staff`; a fire keeps it there. With no Gitea grant evidence for + a chief, the fetch uses slot `library-gitea` and the entry's `user`. Once there is some, only the one + listed chief's live, unheld grant answers, with that grant's user and store file. Anything else + refuses `library-fetch-no-grant`, and so does any Gitea grant evidence in a business with no chief + entry. That refusal opens no file and runs no git. The bus answers its code; the timer's log line + adds `fix: "add the chief of staff, or grant the library through C2-21"`. + +The sync state adds `credential`, `{ kind, user }` of the last fetch, or `null` when none ran. It is +optional, so a state written before C2-03b still reads. + ## Tests ```sh diff --git a/packages/library/bin/askpass b/packages/library/bin/askpass index ee8cbadb6..a400e5fc8 100755 --- a/packages/library/bin/askpass +++ b/packages/library/bin/askpass @@ -2,6 +2,11 @@ # C2-03a: git's GIT_ASKPASS for library sync (packages/library/src/sync.mjs). It answers only the # Username and Password prompts for the configured host and user, and exits 1 on anything else. # The env names the slot path, never the token. printf is a shell builtin, so the token is in no argv. +# C2-03b: the slot is whichever file the bus chose (a grant store file, library-gitea or library-human), +# read with readSecretFile's checks (gitea-grant.mjs): a regular file the bus's uid owns, mode 0600, at +# most 16384 bytes. The checks run on the open fd 3, and its dev:inode must match the path's own +# (an lstat), so a link or a swap before the open refuses and the token comes from the checked file +# (dewey-0860). Residual, as in readSecretFile: a FIFO swapped in after test -f blocks the open. host=$MOSAIC_LIBRARY_HOST user=$MOSAIC_LIBRARY_USER slot=$MOSAIC_LIBRARY_SLOT test -n "$host" && test -n "$user" && test -n "$slot" || exit 1 case $host in *[!a-z0-9.:-]*) exit 1 ;; esac @@ -10,7 +15,14 @@ case $1 in "Username for 'https://$host': ") printf '%s\n' "$user" ;; "Password for 'https://$user@$host': ") test -f "$slot" && test ! -L "$slot" || exit 1 - IFS= read -r token < "$slot" || test -n "$token" || exit 1 + exec 3<"$slot" || exit 1 + open=$(stat -L -c '%d:%i %u %a %s %F' /dev/fd/3 2>/dev/null) || exit 1 + path=$(stat -c '%d:%i' -- "$slot" 2>/dev/null) || exit 1 + set -- $open + test "$1" = "$path" && test "$2" = "$(id -u)" && test "$3" = 600 && test "$4" -le 16384 \ + && test "$5 $6" = "regular file" && test $# -eq 6 || exit 1 + IFS= read -r token <&3 || test -n "$token" || exit 1 + exec 3<&- test -n "$token" || exit 1 printf '%s\n' "$token" ;; *) exit 1 ;; diff --git a/packages/library/src/index.mjs b/packages/library/src/index.mjs index e038309d6..ec797876a 100644 --- a/packages/library/src/index.mjs +++ b/packages/library/src/index.mjs @@ -7,4 +7,4 @@ export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headComm export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs'; export { survey, formatSurvey, surveyMain } from './survey.mjs'; export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs'; -export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs'; +export { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs'; diff --git a/packages/library/src/mirror.mjs b/packages/library/src/mirror.mjs index 0aab7361d..bad515ced 100644 --- a/packages/library/src/mirror.mjs +++ b/packages/library/src/mirror.mjs @@ -219,7 +219,11 @@ async function readStateHeld(mirror, { branch } = {}) { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used))); } catch { fail('library-state-invalid', 'invalid sync state'); } finally { if (fd !== undefined) closeSync(fd); } - if (!keys(value, ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems']) + // C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads. + const listed = ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems']; + if (!keys(value, Object.hasOwn(value ?? {}, 'credential') ? [...listed, 'credential'] : listed) + || !optional(value.credential ?? null, v => keys(v, ['kind', 'user']) && ['slot', 'chief', 'seat', 'human'].includes(v.kind) + && typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user)) || value.version !== 1 || value.branch !== branch || value.served !== served || !optional(value.served, v => typeof v === 'string' && SHA.test(v)) || !optional(value.lastFetch, utc) || !optional(value.lastError, v => keys(v, ['code', 'at']) && typeof v.code === 'string' && /^[a-z-]{1,64}$/.test(v.code) && utc(v.at)) diff --git a/packages/library/src/sync.mjs b/packages/library/src/sync.mjs index 7058aca46..113e0eb08 100644 --- a/packages/library/src/sync.mjs +++ b/packages/library/src/sync.mjs @@ -2,6 +2,9 @@ // configured branch into refs/remotes/library/. A fast-forward moves refs/heads/served, // a non-fast-forward holds it until a human accepts, and a failed fetch keeps serving. The token // never reaches argv, env, config, a URL or a log: git's GIT_ASKPASS helper reads it from the slot. +// C2-03b: the same command pushes, fast-forward only, and the file the helper reads is the one +// credential the caller chose: a seat's grant store file, the chief's for the fetch, slot +// library-gitea before any chief grant, or slot library-human for her save (ruling after r11). import { constants, closeSync, fsyncSync, lstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { dirname, join } from 'node:path'; @@ -15,6 +18,8 @@ import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './ export const ASKPASS = fileURLToPath(new URL('../bin/askpass', import.meta.url)); export const SLOT = 'library-gitea'; +export const HUMAN_SLOT = 'library-human'; +export const CREDENTIAL_KINDS = Object.freeze(['slot', 'chief', 'seat', 'human']); export const SYNC_INTERVAL_MS = 60_000; // Row 199's patterns (packages/seat/src/working.mjs). const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/; @@ -28,9 +33,11 @@ const fail = (code, message = code) => { throw new LibraryError(code, message); // fetch into the same mirror is refused here rather than racing the first. const running = new Set(); -// The one command sync runs against her remote. `kind` is 'fetch'; there is no other. -export function syncCommand(kind, url, { mirror, branch, user, slot }) { - if (kind !== 'fetch') fail('library-remote-invalid', 'sync only fetches'); +// The one command builder for her remote. `kind` is 'fetch' or 'push'. A push sends `commit` to the +// branch with no '+', so the remote takes a fast-forward only (D6). `slot` is the file the helper reads. +export function syncCommand(kind, url, { mirror, branch, user, slot, commit = null }) { + if (kind !== 'fetch' && kind !== 'push') fail('library-remote-invalid', 'sync fetches or pushes'); + if (kind === 'push' && (typeof commit !== 'string' || !SHA.test(commit))) fail('library-remote-invalid', 'a push takes a full commit id'); let parsed; try { parsed = new URL(url); } catch { fail('library-remote-invalid', 'remote must be an https:// URL'); } if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash @@ -38,10 +45,13 @@ export function syncCommand(kind, url, { mirror, branch, user, slot }) { fail('library-remote-invalid', 'remote must be https:// with a plain host and no credentials'); // core.createObject=rename: git's default files each object by link then unlink, and a reader's // inspectMirror (C2-04) refuses a file with two links while that runs. + const command = kind === 'fetch' + ? ['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options', + parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`] + : ['push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options', parsed.href, `${commit}:refs/heads/${branch}`]; return { argv: [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=', - '-c', 'core.createObject=rename', '-C', mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options', - parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`], + '-c', 'core.createObject=rename', '-C', mirror, ...command], env: { ...gitEnv(), LC_ALL: 'C', GIT_CEILING_DIRECTORIES: dirname(mirror), GIT_ASKPASS: ASKPASS, MOSAIC_LIBRARY_HOST: parsed.host, MOSAIC_LIBRARY_USER: user, MOSAIC_LIBRARY_SLOT: slot }, }; @@ -53,6 +63,32 @@ function slotPresent(slot) { catch { return false; } } +// A chosen credential, { kind, user, file }, or { refused: code } when the bus refused one. +function checkCredential(credential) { + if (credential === null || typeof credential !== 'object') fail('library-credential-missing', 'no credential chosen'); + if (Object.hasOwn(credential, 'refused')) return credential; + const { kind, user, file } = credential; + if (!CREDENTIAL_KINDS.includes(kind) || (user !== undefined && (typeof user !== 'string' || !NAME.test(user))) + || (file !== null && typeof file !== 'string')) + fail('library-credential-missing', 'credential must name a kind, a plain user and a file'); + return credential; +} + +// C2-03b: her save and revert push with slot library-human and her Gitea login. A missing login, or a +// slot that is missing, a link, another uid's or not 0600, refuses before any git call. Like sync, this +// never opens the slot; the helper reads it with readSecretFile's checks. +export function humanCredential(context, { user, configDir }) { + const missing = message => fail('library-human-missing', message); + if (typeof user !== 'string' || !NAME.test(user)) missing('her Gitea login is not configured'); + if (typeof configDir !== 'string' || typeof context?.business?.id !== 'string') missing('slot library-human is not configured'); + const file = join(configDir, 'secrets', context.business.id, HUMAN_SLOT); + let st; + try { st = lstatSync(file); } catch { missing('slot library-human is missing'); } + if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384) + missing('slot library-human must be a regular 0600 file the bus owns'); + return { kind: 'human', user, file }; +} + async function isAncestor(mirror, older, newer) { const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer], { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(mirror) }); @@ -89,19 +125,27 @@ const errorCode = error => typeof error?.code === 'string' && CODE.test(error.co // 'library-credential-missing', 'library-diverged' or a reader refusal). It throws for a bad // entry, a busy mirror, a stale --accept or an unreadable state. Sync never runs gc or prune: // a reader holds `served` after the lock is released and reads its objects later (Filbert N2). -export async function sync(context, { slot, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) { +// C2-03b: `credential` is the bus's choice for the fetch, { kind, user, file } or { refused: code }. +// A refusal (library-fetch-no-grant) is recorded like any held fetch, and no file is opened. Without +// it, `slot` is slot library-gitea with the entry's user, as in C2-03a. The state names the kind and +// the user the last fetch ran with, never the file or the value. +export async function sync(context, { slot, credential = null, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) { const mirror = await ensureMirror(context); const mapping = checkMapping(context.business.projects[context.project]); - const { branch, user } = mapping; - if (typeof user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync'); + const { branch } = mapping; + if (typeof mapping.user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync'); + const chosen = credential === null ? { kind: 'slot', file: typeof slot === 'string' ? slot : null } : checkCredential(credential); + const user = chosen.user ?? mapping.user; if (accept !== null && (typeof accept !== 'string' || !SHA.test(accept))) fail('library-accept-invalid', 'accept takes a full commit id'); if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running'); running.add(mirror); try { - let code = null, tip = null, read = null; - if (typeof slot !== 'string' || !slotPresent(slot)) code = 'library-credential-missing'; + let code = null, tip = null, read = null, used = null; + if (Object.hasOwn(chosen, 'refused')) code = typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-fetch-no-grant'; + else if (typeof chosen.file !== 'string' || !slotPresent(chosen.file)) code = 'library-credential-missing'; else { - const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot }); + used = { kind: chosen.kind, user }; + const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot: chosen.file }); const result = await runGit(argv, env, limits); // git's stderr is never kept or logged: the code is the whole record. if (!result.ok) code = 'library-fetch-failed'; @@ -127,7 +171,7 @@ export async function sync(context, { slot, accept = null, command = syncCommand const prev = accept !== null ? { version: 1, branch, served, lastFetch: null, lastError: null, diverged: null, problems: [] } : await readSyncState({ branch }); - let next = { ...prev }, target = served; + let next = { ...prev, credential: used }, target = served; if (code !== null) next.lastError = { code, at }; else if (accept !== null) { const problems = await problemsOf(); @@ -158,3 +202,39 @@ export async function sync(context, { slot, accept = null, command = syncCommand }); } finally { running.delete(mirror); } } + +// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It +// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after +// one fetch with the same credential; the caller re-applies once and calls again with retried: true, +// and a second race refuses stale-base. A hook refusal ([remote rejected]) or any other failure +// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the +// remote-tracking and served refs after a push. +export async function push(context, { commit, credential, retried = false, command = syncCommand, limits = GIT_LIMITS } = {}) { + const mirror = await ensureMirror(context); + const mapping = checkMapping(context.business.projects[context.project]); + const { branch } = mapping; + const chosen = checkCredential(credential); + if (Object.hasOwn(chosen, 'refused')) + fail(typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-credential-missing', 'the bus refused a credential'); + if (typeof chosen.user !== 'string') fail('library-credential-missing', 'a push names its user'); + if (typeof chosen.file !== 'string' || !slotPresent(chosen.file)) + fail(chosen.kind === 'human' ? 'library-human-missing' : 'library-credential-missing', 'credential file missing or unsafe'); + if (typeof commit !== 'string' || !SHA.test(commit)) fail('library-remote-invalid', 'a push takes a full commit id'); + const opts = { mirror, branch, user: chosen.user, slot: chosen.file }; + const { argv, env } = command('push', mapping.remote, { ...opts, commit }); + const result = await runGit(argv, env, limits); + if (result.ok) return { pushed: true }; + // --porcelain: one tab-separated line per ref on stdout, '!' and the summary for a refusal. + const line = String(result.out ?? '').split('\n').find(l => l.split('\t')[1] === `${commit}:refs/heads/${branch}`); + const summary = line?.split('\t')[2] ?? ''; + if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary)) + fail('library-push-failed', 'push refused'); + if (retried) fail('stale-base', 'her branch moved again during the write'); + const fetch = command('fetch', mapping.remote, opts); + if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed'); + let tip; + try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); } + catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); } + if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch'); + return { pushed: false, raced: true, tip }; +} diff --git a/packages/library/tests/sync.test.mjs b/packages/library/tests/sync.test.mjs index 8c55fac22..bfef8c9fc 100644 --- a/packages/library/tests/sync.test.mjs +++ b/packages/library/tests/sync.test.mjs @@ -3,14 +3,14 @@ // argv and adds the CA to its env. The token is made up and must appear nowhere but the slot. import test, { before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; +import { accessSync, chmodSync, constants, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { scratchCa, fakeGitea } from '../../seat/tests/fake-gitea.mjs'; import { SAFE } from '../../project/src/git.mjs'; import { mappingEntry, repo, git, BASE } from './helpers.mjs'; -import { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs'; +import { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs'; import { mirrorPath, readSyncState, readLibraryFile, SYNC_STATE_FILE } from '../src/mirror.mjs'; import { readLibrary } from '../src/reader.mjs'; import { checkMapping } from '../src/mapping.mjs'; @@ -25,9 +25,9 @@ after(() => rmSync(scratch, { recursive: true, force: true })); const clock = () => { let n = 0; return () => new Date(Date.UTC(2026, 9, 21, 12, 0, n++)); }; -async function world(t, { token = TOKEN, redirect = null, user = USER } = {}) { +async function world(t, { token = TOKEN, redirect = null, user = USER, users = { [USER]: token } } = {}) { const f = repo(t); - const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, redirect }); + const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, users, redirect }); t.after(() => gitea.close()); const remote = gitea.repo('writer', 'books'); mkdirSync(dirname(remote), { recursive: true }); @@ -304,3 +304,200 @@ test('C2-03a one sync per mirror: an overlapping call refuses library-sync-busy' assert.equal(results.find(r => r.status === 'rejected').reason.code, 'library-sync-busy'); assert.equal((await w.run()).code, null, 'the mirror is free again afterwards'); }); + +// C2-03b (#1791): push and per-seat credentials. A seat's grant store file is the token plus "\n", as +// Credentials writes it. SEAT_TOKEN is made up too and must also appear nowhere but its file. +const SEAT = 'writer-seat'; +const SEAT_TOKEN = 'fixture-token-71ae-made-up-0044'; + +async function writeWorld(t) { + const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } }); + const seatFile = join(w.root, 'store', 'acme', SEAT, 'gitea'); + mkdirSync(dirname(seatFile), { recursive: true, mode: 0o700 }); + writeFileSync(seatFile, SEAT_TOKEN + '\n', { mode: 0o600 }); + const seat = { kind: 'seat', user: SEAT, file: seatFile }; + assert.equal((await w.run()).code, null); + // A commit on top of `parent` in the mirror, as C2-02's apply would leave it. + const child = (parent, text = 'next') => git(w.mirror(), 'commit-tree', `${parent}^{tree}`, '-p', parent, '-m', text); + const remoteTip = () => git(w.remote, 'rev-parse', 'refs/heads/main'); + const write = (options) => push(w.context, { command: w.command, credential: seat, ...options }); + return { ...w, seatFile, seat, child, remoteTip, write }; +} + +test('C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix', () => { + const opts = { mirror: '/srv/data/projects/acme/books', branch: 'main', user: SEAT, slot: '/srv/store/acme/writer-seat/gitea' }; + const commit = 'c'.repeat(40); + const { argv, env } = syncCommand('push', 'https://git.example.invalid/writer/books.git', { ...opts, commit }); + assert.deepEqual(argv, [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=', + '-c', 'core.createObject=rename', '-C', opts.mirror, 'push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options', + 'https://git.example.invalid/writer/books.git', `${commit}:refs/heads/main`]); + assert.equal(argv.some(a => a.startsWith('+') || a === '--force' || a === '-f' || a.startsWith('--force-')), false, 'no forced update (D6)'); + assert.equal(env.MOSAIC_LIBRARY_SLOT, opts.slot); + assert.equal(env.MOSAIC_LIBRARY_USER, SEAT); + for (const bad of [null, undefined, 'main', 'c'.repeat(39), 'C'.repeat(40), `${'c'.repeat(40)}:refs/heads/x`]) + assert.throws(() => syncCommand('push', 'https://git.example.invalid/b.git', { ...opts, commit: bad }), { code: 'library-remote-invalid' }, String(bad)); + assert.throws(() => syncCommand('gc', 'https://git.example.invalid/b.git', opts), { code: 'library-remote-invalid' }); +}); + +test('C2-03b T1 push: a fast-forward lands with the seat\'s own user and file, and moves no local ref', async t => { + const w = await writeWorld(t); + const before = git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)'); + const next = w.child(w.commit); + w.gitea.requests.length = 0; + assert.deepEqual(await w.write({ commit: next }), { pushed: true }); + assert.equal(w.remoteTip(), next); + assert.ok(w.gitea.requests.length > 0); + assert.ok(w.gitea.requests.every(r => r.user === null || r.user === SEAT), 'only the seat\'s user reaches the remote'); + assert.ok(w.gitea.requests.some(r => r.user === SEAT)); + assert.equal(git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)'), before, 'C2-02 moves the local refs, not push'); + assert.equal(holdsToken(w.dataRoot), null, 'the bot token sits only in the slot'); + for (const name of readdirSync(w.root, { recursive: true })) { + const file = join(w.root, name); + if (file !== w.seatFile && statSync(file).isFile()) assert.equal(readFileSync(file).includes(SEAT_TOKEN), false, file); + } +}); + +test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base', async t => { + const w = await writeWorld(t); + const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' }); + const mine = w.child(w.commit); + const raced = await w.write({ commit: mine }); + assert.deepEqual(raced, { pushed: false, raced: true, tip: moved }); + assert.equal(w.remoteTip(), moved, 'her commit stands: nothing forced it out'); + assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), moved, 'the one fetch ran with the same credential'); + const again = w.push({ 'Inbox Note.md': 'and more\n' }); + await assert.rejects(w.write({ commit: w.child(moved), retried: true }), { code: 'stale-base' }); + assert.equal(w.remoteTip(), again); +}); + +test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => { + const w = await writeWorld(t); + // N2: [remote rejected] is a hook, not a race. + const hook = join(w.remote, 'hooks', 'pre-receive'); + writeFileSync(hook, '#!/bin/sh\necho no >&2\nexit 1\n', { mode: 0o755 }); + const next = w.child(w.commit); + await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' }); + await assert.rejects(w.write({ commit: next, retried: true }), { code: 'library-push-failed' }, 'never stale-base for a hook'); + rmSync(hook); + writeFileSync(w.seatFile, 'fixture-token-wrong-0045\n', { mode: 0o600 }); + await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' }); + writeFileSync(w.seatFile, SEAT_TOKEN + '\n', { mode: 0o600 }); + await w.gitea.close(); + await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' }); + assert.equal(w.remoteTip(), w.commit); +}); + +test('C2-03b push: a refused, missing or unsafe credential runs no git', async t => { + const w = await writeWorld(t); + let called = 0; + const command = (...a) => { called++; return w.command(...a); }; + const next = w.child(w.commit); + w.gitea.requests.length = 0; + const cases = [ + [{ refused: 'library-fetch-no-grant' }, 'library-fetch-no-grant'], + [null, 'library-credential-missing'], + [{ kind: 'seat', file: w.seatFile }, 'library-credential-missing'], + [{ kind: 'seat', user: 'a b', file: w.seatFile }, 'library-credential-missing'], + [{ kind: 'nobody', user: SEAT, file: w.seatFile }, 'library-credential-missing'], + [{ ...w.seat, file: join(w.root, 'absent') }, 'library-credential-missing'], + [{ kind: 'human', user: 'her-login', file: join(w.root, 'absent') }, 'library-human-missing'], + ]; + for (const [credential, code] of cases) + await assert.rejects(push(w.context, { commit: next, credential, command }), { code }, JSON.stringify(credential)); + chmodSync(w.seatFile, 0o644); + await assert.rejects(push(w.context, { commit: next, credential: w.seat, command }), { code: 'library-credential-missing' }); + chmodSync(w.seatFile, 0o600); + await assert.rejects(push(w.context, { commit: 'main', credential: w.seat, command }), { code: 'library-remote-invalid' }); + assert.equal(called, 0); + assert.equal(w.gitea.requests.length, 0); +}); + +test('C2-03b T10 humanCredential: her login and a 0600 slot library-human, or library-human-missing', t => { + const configDir = mkdtempSync(join(tmpdir(), 'library-human-')); + t.after(() => rmSync(configDir, { recursive: true, force: true })); + const context = { business: { id: 'acme' }, project: 'books' }; + const file = join(configDir, 'secrets', 'acme', HUMAN_SLOT); + assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'missing'); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + writeFileSync(file, 'fixture-token-human-0046\n', { mode: 0o600 }); + assert.deepEqual(humanCredential(context, { user: 'her-login', configDir }), { kind: 'human', user: 'her-login', file }); + for (const user of [undefined, null, '', 'a b', 'her@x']) + assert.throws(() => humanCredential(context, { user, configDir }), { code: 'library-human-missing' }, String(user)); + assert.throws(() => humanCredential(context, { user: 'her-login' }), { code: 'library-human-missing' }); + chmodSync(file, 0o644); + assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, '0644'); + chmodSync(file, 0o600); + const real = join(configDir, 'real'); writeFileSync(real, 'x\n', { mode: 0o600 }); + rmSync(file); symlinkSync(real, file); + assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'a link'); +}); + +test('C2-03b fetch credential: the state names the kind and user, a refusal runs no git, and a pre-286 state still reads', async t => { + const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } }); + const first = await w.run(); + assert.equal(first.code, null); + assert.deepEqual(first.state.credential, { kind: 'slot', user: USER }); + assert.deepEqual((await w.state()).credential, { kind: 'slot', user: USER }); + // The chief's grant store file, with the chief's Gitea user. + const chiefFile = join(w.root, 'store', 'acme', 'chief', 'gitea'); + mkdirSync(dirname(chiefFile), { recursive: true, mode: 0o700 }); + writeFileSync(chiefFile, SEAT_TOKEN + '\n', { mode: 0o600 }); + w.gitea.requests.length = 0; + const chief = await w.run({ slot: undefined, credential: { kind: 'chief', user: SEAT, file: chiefFile } }); + assert.equal(chief.code, null); + assert.deepEqual(chief.state.credential, { kind: 'chief', user: SEAT }); + assert.ok(w.gitea.requests.some(r => r.user === SEAT) && w.gitea.requests.every(r => r.user !== USER)); + w.gitea.requests.length = 0; + let called = 0; + const refused = await sync(w.context, { credential: { refused: 'library-fetch-no-grant' }, slot: w.slot, + command: (...a) => { called++; return w.command(...a); }, now: clock() }); + assert.equal(refused.code, 'library-fetch-no-grant'); + assert.equal(refused.state.lastError.code, 'library-fetch-no-grant'); + assert.equal(refused.state.credential, null); + assert.equal(called, 0); + assert.equal(w.gitea.requests.length, 0, 'a refusal opens no file and reaches no remote, even with a valid slot'); + // N1: a state file written before C2-03b has no credential key and still reads. + const file = join(w.mirror(), SYNC_STATE_FILE); + const { credential, ...old } = JSON.parse(readFileSync(file, 'utf8')); + assert.equal(credential, null); + writeFileSync(file, JSON.stringify(old) + '\n'); + assert.deepEqual(await w.state(), old); + writeFileSync(file, JSON.stringify({ ...old, credential: { kind: 'slot', user: USER, file: w.slot } }) + '\n'); + await assert.rejects(w.state(), { code: 'library-state-invalid' }, 'the state never names a file'); +}); + +// T5 and T5b: the helper on a seat's file. `stat` is the one external command between the open and the +// read, so a shim on the helper's PATH stands in for another uid and stages a swap. +test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, another uid, or a swapped file', t => { + const dir = mkdtempSync(join(tmpdir(), 'library-askpass-seat-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const file = join(dir, 'gitea'), other = join(dir, 'other'); + writeFileSync(file, SEAT_TOKEN + '\n', { mode: 0o600 }); + writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 }); + const bin = join(dir, 'bin'); mkdirSync(bin); + const realStat = spawnSync('sh', ['-c', 'command -v stat'], { encoding: 'utf8' }).stdout.trim(); + const env = { PATH: '/usr/bin:/bin', MOSAIC_LIBRARY_HOST: '127.0.0.1:4430', MOSAIC_LIBRARY_USER: SEAT, MOSAIC_LIBRARY_SLOT: file }; + const prompt = `Password for 'https://${SEAT}@127.0.0.1:4430': `; + const ask = (change = {}, p = prompt) => spawnSync(ASKPASS, [p], { env: { ...env, ...change }, encoding: 'utf8' }); + assert.equal(ask().stdout, SEAT_TOKEN + '\n'); + assert.equal(ask({}, `Username for 'https://127.0.0.1:4430': `).stdout, SEAT + '\n'); + const refused = (r, why) => { assert.equal(r.status, 1, why); assert.equal(r.stdout, '', why); }; + refused(ask({}, `Password for 'https://${USER}@127.0.0.1:4430': `), 'another seat\'s user'); + const link = join(dir, 'link'); symlinkSync(file, link); + refused(ask({ MOSAIC_LIBRARY_SLOT: link }), 'a link'); + chmodSync(file, 0o644); refused(ask(), '0644'); + chmodSync(file, 0o640); refused(ask(), '0640'); + chmodSync(file, 0o600); + writeFileSync(other, 'x'.repeat(16385), { mode: 0o600 }); + refused(ask({ MOSAIC_LIBRARY_SLOT: other }), 'over 16384 bytes'); + writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 }); + // Another uid: this suite can't chown, so the shim reports uid+1 for the open file. + writeFileSync(join(bin, 'stat'), `#!/bin/sh\ncase $1 in -L) out=$(${realStat} "$@") || exit 1; set -- $out; printf '%s %s %s %s %s %s\\n' "$1" "$(($2 + 1))" "$3" "$4" "$5" "$6" ;; *) exec ${realStat} "$@" ;; esac\n`, { mode: 0o755 }); + refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'another uid'); + // T5b, the swap: on its first call the shim renames another file over the slot, then stats. + const marker = join(dir, 'swapped'); + writeFileSync(join(bin, 'stat'), `#!/bin/sh\nif [ ! -e '${marker}' ]; then : > '${marker}'; mv -f '${other}' '${file}'; fi\nexec ${realStat} "$@"\n`, { mode: 0o755 }); + refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'a file swapped between the check and the read'); + assert.ok(statSync(marker).isFile(), 'the swap ran'); + assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now'); +});