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

This commit is contained in:
jason.woltje committed 2026-10-24 06:22:44 -05:00
1 parent 299d7f50fe
commit a7f274b005
10 files changed
+614 -24

No files matched your search

+48 -1
View File
@@ -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).
+30
View File
@@ -271,6 +271,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');
@@ -2047,6 +2050,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)) {
+14 -4
View File
@@ -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 <configDir>/secrets/<business>/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
@@ -585,7 +594,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,
@@ -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);
});
+38
View File
@@ -219,6 +219,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 <url>
<commit>:refs/heads/<branch>`, 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, `<storeRoot>/<business>/<agent>/gitea`.
- `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and
`<configDir>/secrets/<business>/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
+13 -1
View File
@@ -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 ;;
+1 -1
View File
@@ -8,7 +8,7 @@ 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';
export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
+5 -1
View File
@@ -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))
+92 -12
View File
@@ -2,6 +2,9 @@
// configured branch into refs/remotes/library/<branch>. 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 };
}
+201 -4
View File
@@ -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');
});