Files
stack/packages/library/src/sync.mjs
T

296 lines
18 KiB
JavaScript

// 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/<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';
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;
}