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.
161 lines
9.3 KiB
JavaScript
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); }
|
|
}
|