Untested: protect library publication state and add broker bare-repo coverage (#1793)

This commit is contained in:
jason.woltje committed 2026-10-23 14:24:34 -05:00
1 parent c867cada97
commit 7dc4fb2cca
7 files changed
+243 -57

No files matched your search

+27
View File
@@ -115,3 +115,30 @@ threads that option into startServer; row C2-11b owns the actual route/page that
webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns
libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional
scope reported to Sage and Fethry. No changes to an existing test. scope reported to Sage and Fethry. No changes to an existing test.
## 2026-10-23 second static pass and dependency review
First push: c867cada9 on origin/bubba/row288, reported bubba-0005 to Sage/Webby/Mos and
bubba-0006 to Fethry/Ludwig. No PR created and no review/gate requested as complete.
Ludwig ludwig-0065 checked the exact sync diff against 286 without running anything. Token identity,
ordinary-sync exclusion, 286 R1 preservation, guard key and end-of-write tracking restoration fit.
F1: state publication could fail after served moved, leaving old state/new served. Root cause is
publication order without rollback, compared against sync's existing rollback. Added an unrun
filesystem rename-failure regression, then changed the narrow helper: on failure it validates the
state under the same lock, CAS-restoring served if state has not published the new commit. If rename
succeeded but directory fsync failed and the state validates against new served, it keeps that pair.
All failure cases answer outcome-unknown; tracking remains the commit already pushed remotely.
Verification is pending CLEAR, so this is a proposed fix, not a tested resolution.
Added the actual broker-to-bare tests for each M12 path refusal (both agent verbs), unchanged remote
and all mirror refs, exact write/delete notices, and human revert after fixture grant removal/hold.
The bare fixture moved to new library/tests/write-fixture.mjs so broker tests reuse real plumbing.
Added the raw socket proof-nested-save-name case to pin the parsed-verb large-request check.
Missing editor/slot tests now make the mirror metadata invalid first: reaching Git would change the
refusal, so the tests check the pre-Git gate rather than merely count remote commands.
Additional required doc hunk: packages/library/README.md appends the four verb contracts,
lastPush shape, exact notice, lock tradeoff and publication failure behavior. No existing test from
the dependency/base has changed. All new/modified tests in this pass were authored by this row.
`git diff --check` remains clean. No Node/tests/build/mutants run; 284 and CLEAR remain pending.
+67 -2
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs'; import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { connect } from 'node:net';
import { Store } from '../src/store.mjs'; import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs'; import { Broker } from '../src/broker.mjs';
import { Client } from '../src/client.mjs'; import { Client } from '../src/client.mjs';
@@ -11,12 +12,15 @@ import { serve } from '../src/server.mjs';
import { mappingEntry } from '../../library/tests/helpers.mjs'; import { mappingEntry } from '../../library/tests/helpers.mjs';
import { LibraryError } from '../../library/src/problems.mjs'; import { LibraryError } from '../../library/src/problems.mjs';
import { grantDecision } from './host-fixtures.mjs'; import { grantDecision } from './host-fixtures.mjs';
import { world as bareWorld } from '../../library/tests/write-fixture.mjs';
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../../library/src/write.mjs';
import { git } from '../../library/tests/helpers.mjs';
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md'; const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
function world(t) { function world(t, options = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root); const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const projects = { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/']; const projects = options.projects ?? { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] }; const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] }, const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } }; chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
@@ -110,3 +114,64 @@ test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remai
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' }); await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' }); await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
}); });
test('C2-02 M12 through broker: every refusal preserves remote, served and all mirror refs', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
for (const path of ['Book One/Chapters/Chapter 01.md', 'Inbox Note.md', 'Book One/Playground/What If.md',
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md',
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]) {
for (const verb of ['library.doc.write', 'library.doc.delete']) {
const before = bare.refs(), head = bare.remoteHead(), a = { ...args(), path, base: null };
if (verb.endsWith('delete')) delete a.content;
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-story-path' }, path);
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
}
}
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
});
test('C2-02 broker + bare: exact write/delete notices, then human revert with revoked seat grant', async t => {
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
f.library.revert = context => revertLibraryCommit({ ...context, dataRoot: bare.dataRoot },
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
f.library.saveFile = context => saveLibraryFile({ ...context, dataRoot: bare.dataRoot },
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
const base = git(bare.mirror, 'rev-parse', `served:${PATH}`);
const saved = await f.call('bean', 'library.doc.write', { ...args(), base });
assert.equal(saved.commit, bare.remoteHead());
f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
const reverted = await f.call('human', 'library.revert', { project: 'books', commit: saved.commit });
assert.equal(reverted.reverted, saved.commit);
assert.equal(git(bare.remote, 'show', `main:${PATH}`), 'before');
assert.equal(git(bare.remote, 'show', '-s', '--format=%an <%ae>', reverted.commit), 'Human Fixture <[email protected]>');
f.held.clear(); f.grant();
const deleted = await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base });
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human' ORDER BY seq");
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
{ notice: 'library-write', op: 'write', path: PATH, commit: saved.commit },
{ notice: 'library-write', op: 'delete', path: PATH, commit: deleted.commit }]);
assert.equal(git(bare.remote, 'ls-tree', '-r', deleted.commit, '--', PATH), '');
});
test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope', async t => {
const f = world(t), path = join(f.root, 'raw-bus.sock');
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.caps.human });
t.after(() => server.close());
const request = JSON.stringify({ human: { verb: 'library.file.save' }, verb: 'library.doc.write',
args: { ...args(), content: 'x'.repeat(70000) } }) + '\n';
const response = await new Promise((resolve, reject) => {
const socket = connect(path); let result = '';
socket.on('error', reject); socket.on('connect', () => socket.write(request));
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
});
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
assert.equal(f.seen.length, 0);
});
+44
View File
@@ -263,3 +263,47 @@ node --test packages/library/tests/
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
the survey tests check that it never appears in output. the survey tests check that it never appears in output.
### C2-02 supporting writes and human saves
The host exposes `library.doc.write {universe,path,base,content}` and
`library.doc.delete {universe,path,base}` only to a creative session holding
`library.write` and a live, unheld Gitea grant for the library project. The
broker chooses the project from the universe mapping, the author/committer
from the instance identity, and the credential from its grant store. `base`
is the served-tree blob id, or `null` to create. Supporting-path checks run
before remote effects and against each fetched tip; deletes require an
existing `100644` blob. Content is UTF-8 without NUL and at most 1 MiB;
agent requests still have the ordinary 64 KiB socket envelope limit.
Each operation is one plumbing commit with one parent, a fixed
`Mosaic: write <path>` or `Mosaic: delete <path>` message, and the trailer
`Mosaic-Agent: <business>/<instance>`. Pushes are fast-forward only. A moved
remote tip retries once when the target blob still equals `base`; otherwise
it refuses `stale-base`. A failed push leaves no queued work or local ref
change. A successful push updates served/tracking, rebuilds the index and
records `lastPush: {commit,at}`. The broker inserts the exact fixed notice
`{notice:"library-write",op,path,commit}` from the writer to the human in
that thread, independently of the agent-message citation guard.
`library.file.save {project,path,base,content}` and
`library.revert {project,commit}` are human-only. Project is the business
project key. A save reaches any mapped `.md` file under the structural path
rules, uses the mapping's `editor` as author and committer, and pushes with
slot `library-human`. Revert accepts only a reachable, one-parent,
one-path commit with this business's agent trailer, and restores its
parent's blob with the agent commit's blob as `base`. The result is
`{project,path,reverted,commit}`. Revoking/firing the original writer does
not prevent a human revert. Missing editor refuses
`library-mapping-invalid`; missing/unsafe human slot refuses
`library-human-missing`, before Git. An agent calling either human verb
gets `human-required`. Only `library.file.save` joins the larger Console
save envelope exception; agent writes retain 64 KiB.
The write guard excludes concurrent sync and admits a race fetch only for
its opaque owner token. The library flock spans fetch/build/push as the
brief requires, so readers can receive `library-sync-busy` after the
existing five-second lock wait while network Git runs (MVP tradeoff,
sage-3628). Publication errors return `outcome-unknown`: served is restored
to match old state unless the new state was already published. The remote
commit remains; callers must inspect status rather than retry blindly.
+16
View File
@@ -269,10 +269,26 @@ export async function publishLibraryWrite(mirror, { mapping, commit, previous, r
const tracking = `refs/remotes/library/${mapping.branch}`; const tracking = `refs/remotes/library/${mapping.branch}`;
// The remote push already succeeded. A local publication failure is outcome-unknown to the // The remote push already succeeded. A local publication failure is outcome-unknown to the
// caller; it must never encourage an automatic second push. // caller; it must never encourage an automatic second push.
let moved = false;
try { try {
await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]); await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]);
moved = true;
await git(mirror, ['update-ref', tracking, commit]); await git(mirror, ['update-ref', tracking, commit]);
writeState(mirror, next); writeState(mirror, next);
} catch {
if (moved) {
// A rename may have succeeded before directory fsync failed. Keep the new ref when the
// validated state already matches it; otherwise restore served by compare-and-swap.
let published = false;
try { published = (await readSyncState({ branch: mapping.branch })).served === commit; } catch {}
if (!published) {
try {
await git(mirror, ['update-ref', ...(previous === null
? ['-d', 'refs/heads/served', commit] : ['refs/heads/served', previous, commit])]);
} catch { fail('outcome-unknown'); } } catch { fail('outcome-unknown'); }
}
}
fail('outcome-unknown');
}
return next; return next;
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import { classify } from './scope.mjs';
import { supportingPath } from './supporting.mjs'; import { supportingPath } from './supporting.mjs';
import { LibraryError } from './problems.mjs'; import { LibraryError } from './problems.mjs';
import { listTree, MAX_BLOB } from './reader.mjs'; import { listTree, MAX_BLOB } from './reader.mjs';
import { mirrorPath, ensureMirror, servedCommit, withLibraryLock } from './mirror.mjs'; import { mirrorPath, ensureMirror, withLibraryLock } from './mirror.mjs';
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs'; import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
const SHA = /^[a-f0-9]{40}$/; const SHA = /^[a-f0-9]{40}$/;
+56
View File
@@ -0,0 +1,56 @@
// Shared scratch bare-repository fixture for row 288 library and broker tests.
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { repo, git, mappingEntry, BASE } from './helpers.mjs';
import { sync, syncCommand } from '../src/sync.mjs';
import { mirrorPath, readSyncState } from '../src/mirror.mjs';
export const PATH = 'Book One/Notes/Agent.md';
export const IDENTITY = { name: 'Bean Fixture', email: '[email protected]' };
export const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
export async function world(t) {
const f = repo(t, { ...BASE, [PATH]: 'before\n',
'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 },
'Book One/Notes/Link.md': { link: 'Agent.md' } });
git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md');
git(f.repo, 'commit', '-qm', 'gitlink fixture');
const remote = join(f.root, 'remote.git');
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const entry = { ...mappingEntry(), user: 'human-fixture', editor: EDITOR };
entry.universes.harbor.supporting = ['Book One/Notes/'];
const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot };
const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 });
const credential = { kind: 'seat', user: 'bean-fixture', file };
// Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote.
let beforePush = null, reject = false;
const commands = [];
const command = (kind, url, opts) => {
commands.push(kind);
if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); }
const c = syncCommand(kind, url, opts);
const argv = [...c.argv];
argv.splice(0, 0, '-c', 'protocol.file.allow=always');
const at = argv.indexOf(url);
assert.ok(at >= 0);
argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote;
return { argv, env: c.env };
};
await sync(context, { credential, command });
const mirror = mirrorPath(context);
const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)');
const remoteHead = () => git(remote, 'rev-parse', 'main');
const state = () => readSyncState(mirror, { branch: 'main' });
const external = (path, content) => {
writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external');
git(f.repo, 'push', '-q', remote, 'HEAD:main');
};
const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH,
base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra });
const opts = { credential, identity: IDENTITY, agent: 'acme/bean', command };
return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts,
race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands };
}
+32 -54
View File
@@ -1,62 +1,15 @@
// Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures. // Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { writeFileSync, existsSync, renameSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { repo, git, mappingEntry, BASE } from './helpers.mjs'; import { git } from './helpers.mjs';
import { SAFE } from '../../project/src/git.mjs'; import { sync, push, withLibraryWriteGuard } from '../src/sync.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs'; import { servedCommit, withLibraryLock, readSyncState, SYNC_STATE_FILE } from '../src/mirror.mjs';
import { sync, syncCommand, push, withLibraryWriteGuard } from '../src/sync.mjs'; import { publishLibraryWrite } from '../src/sync.mjs';
import { mirrorPath, readSyncState, servedCommit } from '../src/mirror.mjs'; import { checkMapping } from '../src/mapping.mjs';
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs'; import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs';
import { world, PATH, EDITOR } from './write-fixture.mjs';
const PATH = 'Book One/Notes/Agent.md';
const IDENTITY = { name: 'Bean Fixture', email: '[email protected]' };
const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
async function world(t, options = {}) {
const f = repo(t, { ...BASE, [PATH]: 'before\n',
'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 },
'Book One/Notes/Link.md': { link: 'Agent.md' } });
git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md');
git(f.repo, 'commit', '-qm', 'gitlink fixture');
const remote = join(f.root, 'remote.git');
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const entry = { ...mappingEntry(), user: 'human-fixture', editor: EDITOR };
entry.universes.harbor.supporting = ['Book One/Notes/'];
const context = { business: { id: 'acme', projects: { books: entry } }, project: 'books', dataRoot };
const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 });
const credential = { kind: 'seat', user: 'bean-fixture', file };
// Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote.
let beforePush = null, reject = false;
const commands = [];
const command = (kind, url, opts) => {
commands.push(kind);
if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); }
const c = syncCommand(kind, url, opts);
const argv = [...c.argv];
argv.splice(0, 0, '-c', 'protocol.file.allow=always');
const at = argv.indexOf(url);
assert.ok(at >= 0);
argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote;
return { argv, env: c.env };
};
await sync(context, { credential, command });
const mirror = mirrorPath(context);
const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)');
const remoteHead = () => git(remote, 'rev-parse', 'main');
const state = () => readSyncState(mirror, { branch: 'main' });
const external = (path, content) => {
writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external');
git(f.repo, 'push', '-q', remote, 'HEAD:main');
};
const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH,
base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra });
const opts = { credential, identity: IDENTITY, agent: 'acme/bean', command };
return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts,
race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands };
}
test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => { test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => {
const f = await world(t), parent = f.remoteHead(); const f = await world(t), parent = f.remoteHead();
@@ -157,11 +110,14 @@ test('C2-02: invalid content/editor/human credential refuse before any git comma
for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)]) for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)])
await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' }); await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' });
const args = { ...f.context, path: PATH, base: null, content: 'x' }; const args = { ...f.context, path: PATH, base: null, content: 'x' };
writeFileSync(join(f.mirror, 'unexpected'), 'any local git inspection must refuse\n');
delete f.entry.editor; delete f.entry.editor;
await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' }); await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
f.entry.editor = EDITOR; f.entry.editor = EDITOR;
const n = f.commands.length; const n = f.commands.length;
await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' }); await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
assert.equal(f.commands.length, n); assert.equal(f.refs(), before); assert.equal(f.commands.length, n); assert.equal(f.refs(), before);
}); });
@@ -177,3 +133,25 @@ test('C2-02 guard: only the exact owning token permits a push race fetch; sync r
assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead()); assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead());
}); });
}); });
test('C2-02 publication F1: failed state rename restores served to match prior state', async t => {
const f = await world(t), previous = await servedCommit(f.mirror);
const commit = git(f.mirror, 'commit-tree', git(f.mirror, 'rev-parse', `${previous}^{tree}`), '-p', previous, '-m', 'candidate');
const statePath = join(f.mirror, SYNC_STATE_FILE), backup = join(f.root, 'state-backup');
await withLibraryLock(f.mirror, async lock => {
// A filesystem failure after the locked state read, before state publication. The original
// state is saved outside the mirror and restored after the failed rename.
const read = async options => {
const state = await lock.readSyncState(options);
renameSync(statePath, backup); mkdirSync(statePath, { mode: 0o700 });
return state;
};
try {
await assert.rejects(publishLibraryWrite(f.mirror, { mapping: checkMapping(f.entry), commit, previous, readSyncState: read }),
{ code: 'outcome-unknown' });
assert.equal(git(f.mirror, 'rev-parse', 'served'), previous);
} finally { rmSync(statePath, { recursive: true, force: true }); renameSync(backup, statePath); }
});
assert.equal((await readSyncState(f.mirror, { branch: 'main' })).served, previous);
});