// C2-03a: fetch-only sync of her repo into the C2-04 mirror (design 7.2). git fetches the // configured branch into refs/remotes/library/. A fast-forward moves refs/heads/served, // a non-fast-forward holds it until a human accepts, and a failed fetch keeps serving. The token // never reaches argv, env, config, a URL or a log: git's GIT_ASKPASS helper reads it from the slot. // C2-03b: the same command pushes, fast-forward only, and the file the helper reads is the one // credential the caller chose: a seat's grant store file, the chief's for the fetch, slot // library-gitea before any chief grant, or slot library-human for her save (ruling after r11). import { constants, closeSync, fsyncSync, lstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs'; import { SAFE, git } from '../../project/src/git.mjs'; import { checkMapping } from './mapping.mjs'; import { LibraryError } from './problems.mjs'; import { readLibrary } from './reader.mjs'; import { indexFor } from './index-build.mjs'; import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './mirror.mjs'; 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})?$/; const NAME = /^[A-Za-z0-9_.-]{1,100}$/; const SHA = /^[a-f0-9]{40}$/; const CODE = /^[a-z-]{1,64}$/; const STATE_MAX = 8 * 1024 * 1024; const ZERO = '0'.repeat(40); const fail = (code, message = code) => { throw new LibraryError(code, message); }; // One sync per mirror in this process. The library lock covers publication only, so a second // fetch into the same mirror is refused here rather than racing the first. const running = new Set(); // C2-02: opaque identity lets only the owning write re-enter push's race fetch. const writers = new Map(); export async function withLibraryWriteGuard(mirror, operation) { if (running.has(mirror)) fail('library-sync-busy'); const token = Object.freeze({}); running.add(mirror); writers.set(mirror, token); try { return await operation(token); } finally { writers.delete(mirror); running.delete(mirror); } } // 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 || !HOST.test(parsed.host) || typeof user !== 'string' || !NAME.test(user)) 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, ...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 }, }; } // Present and a regular file. Sync never opens it; only the helper reads it. function slotPresent(slot) { try { const st = lstatSync(slot); return st.isFile() && st.uid === process.getuid() && (st.mode & 0o077) === 0; } 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) }); if (result.ok) return true; if (result.why === 'exit 1') return false; fail('library-git', 'ancestry check refused'); } // Temp file beside the mirror (inspectMirror refuses unknown names inside it), then one rename. function writeState(mirror, state) { const body = Buffer.from(JSON.stringify(state) + '\n'); if (body.length > STATE_MAX) fail('library-state-invalid', 'sync state over 8 MiB'); const temp = join(dirname(mirror), `.library-state-${randomBytes(8).toString('hex')}`); let fd; try { fd = openSync(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); for (let at = 0; at < body.length;) at += writeSync(fd, body, at); fsyncSync(fd); closeSync(fd); fd = undefined; renameSync(temp, join(mirror, SYNC_STATE_FILE)); const dir = openSync(mirror, constants.O_RDONLY | constants.O_DIRECTORY); try { fsyncSync(dir); } finally { closeSync(dir); } } catch (error) { if (fd !== undefined) closeSync(fd); rmSync(temp, { force: true }); if (error instanceof LibraryError) throw error; fail('library-state-invalid', 'sync state write refused'); } } const errorCode = error => typeof error?.code === 'string' && CODE.test(error.code) ? error.code : 'library-git'; // sync(context, { slot, accept, command, now, limits }) fetches once and publishes the result. // It resolves { state, code }: code is null, or why served did not move ('library-fetch-failed', // '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). // 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 } = 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, 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 { 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'; else { try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); } catch { code = 'library-fetch-failed'; } } } if (tip !== null && !SHA.test(tip)) { tip = null; code = 'library-fetch-failed'; } if (accept !== null && code === null && accept !== tip) fail('library-accept-stale', 'accept names a commit other than the fetched tip'); // Problems belong to the tip alone. Read them before the lock: readers wait only 5 s for it. const problemsOf = async () => { try { read = read ?? await readLibrary({ repo: mirror, commit: tip, mapping }); return read.problems.map(p => ({ ...p })); } catch (error) { code = errorCode(error); return null; } }; if (tip !== null && (accept !== null || tip !== await servedCommit(mirror))) await problemsOf(); if (code !== null && accept !== null) accept = null; return await withLibraryLock(mirror, async ({ readSyncState }) => { const at = now().toISOString(); const served = await servedCommit(mirror); // --accept starts the record over, so it also clears a state from another branch. const prev = accept !== null ? { version: 1, branch, served, lastFetch: null, lastError: null, diverged: null, problems: [] } : await readSyncState({ branch }); let next = { ...prev, credential: used }, target = served; if (code !== null) next.lastError = { code, at }; else if (accept !== null) { const problems = await problemsOf(); if (problems === null) next.lastError = { code, at }; else { target = tip; next = { ...next, served: tip, lastFetch: at, lastError: null, diverged: null, problems }; } } else if (served === tip) next = { ...next, lastFetch: at, lastError: null, diverged: null }; else if (prev.diverged !== null || (served !== null && !await isAncestor(mirror, served, tip))) { // Held until a human accepts, even if a later tip is a fast-forward again. `at` stays the // first detection; `commit` follows the tip, which is what --accept must name. code = 'library-diverged'; next = { ...next, lastFetch: at, lastError: null, diverged: { commit: tip, at: prev.diverged?.at ?? at } }; } else { const problems = await problemsOf(); if (problems === null) next = { ...next, lastFetch: at, lastError: { code, at } }; else { target = tip; next = { ...next, served: tip, lastFetch: at, lastError: null, problems }; } } const moveServed = async args => { try { await git(mirror, ['update-ref', ...args]); } catch { fail('library-git', 'served ref update refused'); } }; if (target !== served) await moveServed(['refs/heads/served', target, served ?? ZERO]); try { writeState(mirror, next); } catch (error) { // The ref goes back, so served and the state on disk still match. if (target !== served) await moveServed(served === null ? ['-d', 'refs/heads/served', target] : ['refs/heads/served', served, target]); throw error; } return { state: next, code }; }); } 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. The fetch refuses library-sync-busy while a sync of the same // mirror runs. 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, writeToken = null, 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'); // The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses // library-sync-busy and the caller tries the write again. const owned = writeToken !== null && writers.get(mirror) === writeToken; if (running.has(mirror) && !owned) fail('library-sync-busy', 'a sync of this library is running'); if (!owned) running.add(mirror); try { 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 }; } finally { if (!owned) running.delete(mirror); } } // Called under the publication lock by C2-02; the raw state writer stays private. export async function publishLibraryWrite(mirror, { mapping, commit, previous, readSyncState, now = () => new Date() }) { if (!SHA.test(commit) || (previous !== null && !SHA.test(previous))) fail('library-state-invalid'); const state = await readSyncState({ branch: mapping.branch }); if (state.diverged !== null) fail('library-diverged'); if (state.served !== previous) fail('library-state-invalid'); const read = await indexFor({ repo: mirror, commit, mapping }); const at = now().toISOString(); const next = { ...state, served: commit, lastPush: { commit, at }, lastError: null, problems: read.problems.map(p => ({ ...p })) }; 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 { 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; }