Files
stack/packages/library/src/sync.mjs
T
jason.woltje 0b33f5e9db row 222: C2-03a fetch-only library sync, askpass helper, bus verb and timer (#1723)
Candidate c9aa9c2ee (megavolt, manifest c2121b55, 15 paths), approved by
Dewey (rev 2966, comment 30138). Gate on Sage's slot by Megavolt,
20:08:37Z to 20:11:33Z: library 77/77, bus 609/609, webui library-cli 1/1
and pinned-copy 10/10, cli stack-copy 17/17, every scripts/test-*.sh rc=0,
fixture tokens 0 hits; mutant kills carried from 4a0e0ab3a with equal blobs.
2026-10-21 15:13:31 -05:00

161 lines
9.3 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.
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 { 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 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();
// The one command sync runs against her remote. `kind` is 'fetch'; there is no other.
export function syncCommand(kind, url, { mirror, branch, user, slot }) {
if (kind !== 'fetch') fail('library-remote-invalid', 'sync only fetches');
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.
return {
argv: [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
'-c', 'core.createObject=rename', '-C', mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
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; }
}
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).
export async function sync(context, { slot, 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, user } = mapping;
if (typeof user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
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;
if (typeof slot !== 'string' || !slotPresent(slot)) code = 'library-credential-missing';
else {
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot });
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 }, 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); }
}