296 lines
18 KiB
JavaScript
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;
|
|
}
|