From 7dc4fb2cca8716b94a72d303aeaf30376b20a723 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:24:34 -0500 Subject: [PATCH] Untested: protect library publication state and add broker bare-repo coverage (#1793) --- agents/bubba/work/queue-288/BUILD.md | 27 +++++++ packages/bus/tests/library-write.test.mjs | 69 +++++++++++++++++- packages/library/README.md | 44 ++++++++++++ packages/library/src/sync.mjs | 18 ++++- packages/library/src/write.mjs | 2 +- packages/library/tests/write-fixture.mjs | 56 +++++++++++++++ packages/library/tests/write.test.mjs | 86 +++++++++-------------- 7 files changed, 244 insertions(+), 58 deletions(-) create mode 100644 packages/library/tests/write-fixture.mjs diff --git a/agents/bubba/work/queue-288/BUILD.md b/agents/bubba/work/queue-288/BUILD.md index e695db006..6874e1cdf 100644 --- a/agents/bubba/work/queue-288/BUILD.md +++ b/agents/bubba/work/queue-288/BUILD.md @@ -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 libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional 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. diff --git a/packages/bus/tests/library-write.test.mjs b/packages/bus/tests/library-write.test.mjs index 1859d8129..0bfbbfd7f 100644 --- a/packages/bus/tests/library-write.test.mjs +++ b/packages/bus/tests/library-write.test.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { connect } from 'node:net'; import { Store } from '../src/store.mjs'; import { Broker } from '../src/broker.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 { LibraryError } from '../../library/src/problems.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'; -function world(t) { +function world(t, options = {}) { const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root); 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 roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: 'bean@example.test', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] }, 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(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 '); + 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); +}); diff --git a/packages/library/README.md b/packages/library/README.md index 2ac5ea969..b5246232f 100644 --- a/packages/library/README.md +++ b/packages/library/README.md @@ -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 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 ` or `Mosaic: delete ` message, and the trailer +`Mosaic-Agent: /`. 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. diff --git a/packages/library/src/sync.mjs b/packages/library/src/sync.mjs index 5277f723c..97fd53de1 100644 --- a/packages/library/src/sync.mjs +++ b/packages/library/src/sync.mjs @@ -269,10 +269,26 @@ export async function publishLibraryWrite(mirror, { mapping, commit, previous, r const tracking = `refs/remotes/library/${mapping.branch}`; // The remote push already succeeded. A local publication failure is outcome-unknown to the // caller; it must never encourage an automatic second push. + let moved = false; try { await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]); + moved = true; await git(mirror, ['update-ref', tracking, commit]); writeState(mirror, next); - } catch { fail('outcome-unknown'); } + } 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'); } + } + } + fail('outcome-unknown'); + } return next; } diff --git a/packages/library/src/write.mjs b/packages/library/src/write.mjs index 1b1dc5c8c..8d0c7a0e1 100644 --- a/packages/library/src/write.mjs +++ b/packages/library/src/write.mjs @@ -8,7 +8,7 @@ import { classify } from './scope.mjs'; import { supportingPath } from './supporting.mjs'; import { LibraryError } from './problems.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'; const SHA = /^[a-f0-9]{40}$/; diff --git a/packages/library/tests/write-fixture.mjs b/packages/library/tests/write-fixture.mjs new file mode 100644 index 000000000..81f0c812e --- /dev/null +++ b/packages/library/tests/write-fixture.mjs @@ -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: 'bean@example.test' }; +export const EDITOR = { name: 'Human Fixture', email: 'human@example.test' }; + +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 }; +} + diff --git a/packages/library/tests/write.test.mjs b/packages/library/tests/write.test.mjs index 6ae8db4f8..6294dd7b0 100644 --- a/packages/library/tests/write.test.mjs +++ b/packages/library/tests/write.test.mjs @@ -1,62 +1,15 @@ // Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures. import test from 'node:test'; 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 { repo, git, mappingEntry, BASE } from './helpers.mjs'; -import { SAFE } from '../../project/src/git.mjs'; -import { gitEnv } from '../../seat/src/checkout.mjs'; -import { sync, syncCommand, push, withLibraryWriteGuard } from '../src/sync.mjs'; -import { mirrorPath, readSyncState, servedCommit } from '../src/mirror.mjs'; +import { git } from './helpers.mjs'; +import { sync, push, withLibraryWriteGuard } from '../src/sync.mjs'; +import { servedCommit, withLibraryLock, readSyncState, SYNC_STATE_FILE } from '../src/mirror.mjs'; +import { publishLibraryWrite } from '../src/sync.mjs'; +import { checkMapping } from '../src/mapping.mjs'; import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs'; - -const PATH = 'Book One/Notes/Agent.md'; -const IDENTITY = { name: 'Bean Fixture', email: 'bean@example.test' }; -const EDITOR = { name: 'Human Fixture', email: 'human@example.test' }; - -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 }; -} +import { world, PATH, EDITOR } from './write-fixture.mjs'; 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(); @@ -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)]) await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' }); 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; 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; 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(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); }); @@ -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()); }); }); + + +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); +});