/** * Server-side branch protection: the write guard a PDS applies to git repo * records before it accepts them. * * Rules live in a dev.pdsjs.git.config record beside the repo record, under * the same rkey. A separate record rather than a field: every push rebuilds * the repo record whole, so a field would vanish on the first push, and the * config must outlive them all. The guard reads the config at write time and * refuses a repo-record write that force-pushes, deletes, or drops a * protected branch. Rules bind the copy on the enforcing PDS; a member's * copy on their own server answers to that server. * * The same record holds the settings no guard enforces, the check runner and * the linked packages, so their readers are here too. * * Workers-clean: pack parsing only, no node imports. */ import { parseBundle } from './bundle.js'; import { indexPacks, parseCommit, resolveToCommit } from './objects.js'; import { concatChunks, indexPartsOf, parseRepoRecord } from './record.js'; import { createLazyStore } from './store.js'; export const GIT_CONFIG_COLLECTION = 'dev.pdsjs.git.config'; // A walk from the new tip must find the old tip within this many commits for // a push to count as fast-forward. A history deeper than this between two // pushes is outside personal scale; refusing keeps the check bounded. const MAX_WALK = 100_000; export class GitRuleError extends Error { /** * @param {string} message * @param {string} [code] - machine-readable error name for the response */ constructor(message, code = 'ProtectedBranch') { super(message); this.name = 'GitRuleError'; this.code = code; } } /** * The protected branch names a config value carries. Anything malformed * counts as unprotected rather than failing the write it gates. * @param {unknown} config * @returns {string[]} */ export function protectedBranchesOf(config) { const list = /** @type {{protectedBranches?: unknown}} */ (config ?? {}) .protectedBranches; if (!Array.isArray(list)) return []; return list.filter( (branch) => typeof branch === 'string' && branch.length > 0, ); } /** * The DID a config value names as the repository's check runner. A reader * asks that account's PDS for check records and trusts no other. Anything * malformed counts as no runner. * @param {unknown} config * @returns {string|null} */ export function runnerOf(config) { const runner = /** @type {{runner?: unknown}} */ (config ?? {}).runner; return typeof runner === 'string' && runner.startsWith('did:') ? runner : null; } /** * Every runner DID a config value names: the `runners` list, or the single * `runner` where there is no list. * * One runner can forge nothing, because a reader follows its index back to * the records it names. What one runner can do is omit. Naming several means * a pull request is found if any one of them indexed it, and each is trusted * no further than the checks already are. * @param {unknown} config * @returns {string[]} */ export function runnersOf(config) { const listed = didList( /** @type {{runners?: unknown}} */ (config ?? {}).runners, ); if (listed.length > 0) return listed; const single = runnerOf(config); return single ? [single] : []; } /** * The DIDs a config value names as collaborators: the accounts holding a copy * of this repository whose branches count as work on it. atproto has no * reverse index and a copy names no upstream, so this list is the only thing * connecting the canonical repository to the copies on other servers. * * The account holding the config is not in the list. It works on its own * repository by definition, and a reader adds it. * @param {unknown} config * @returns {string[]} */ export function collaboratorsOf(config) { return didList( /** @type {{collaborators?: unknown}} */ (config ?? {}).collaborators, ); } /** * The DIDs a config value names as reviewers, whose review records a reader * shows. A review is a statement by an account about a commit, published in * that account's own repo, so anyone may write one about any public * repository. This list is the repository's answer to whose statements it * puts on screen. * @param {unknown} config * @returns {string[]} */ export function reviewersOf(config) { return didList(/** @type {{reviewers?: unknown}} */ (config ?? {}).reviewers); } /** * @param {unknown} list * @returns {string[]} the DIDs in it, in order, without repeats */ function didList(list) { if (!Array.isArray(list)) return []; const dids = list.filter( (did) => typeof did === 'string' && did.startsWith('did:'), ); return [...new Set(dids)]; } /** The registries a repository may name a package in. */ export const PACKAGE_REGISTRIES = ['npm', 'oci']; /** * @typedef {Object} LinkedPackage * @property {string} registry - one of PACKAGE_REGISTRIES * @property {string} name - the package name in that registry */ /** * The packages a config value names as built from this repository. atproto * has no reverse index and a package record holds no repository name, so the * link is this list and nothing else. An entry naming a registry this build * has no words for is dropped, which is how an older reader treats a * registry added later. * @param {unknown} config * @returns {LinkedPackage[]} */ export function linkedPackagesOf(config) { const list = /** @type {{packages?: unknown}} */ (config ?? {}).packages; if (!Array.isArray(list)) return []; /** @type {LinkedPackage[]} */ const packages = []; for (const entry of list) { const { registry, name } = /** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {}); if (typeof registry !== 'string' || typeof name !== 'string') continue; if (!PACKAGE_REGISTRIES.includes(registry) || name.length === 0) continue; packages.push({ registry, name }); } return packages; } /** * @param {unknown} value - a git repo record value * @returns {Map} ref name to sha */ function refMap(value) { try { const record = parseRepoRecord(value); return new Map(record.refs.map((ref) => [ref.name, ref.sha])); } catch { return new Map(); } } /** * The commit graph, read through the object index so the server holds the * commits alone. A chain whose bundles all carry an index, on a store that * reads ranges, costs the index blobs and the commits; every other chain * downloads whole. * @param {import('./record.js').GitRepoRecord} record * @param {(cid: string) => Promise} getBlob * @param {(cid: string, start: number, end: number) => Promise} getBlobRange * @returns {Promise|null>} the * commits and tags by id, or null if the chain has no index to read */ async function loadCommitsLazily(record, getBlob, getBlobRange) { if (record.bundles.length === 0) return null; if (record.bundles.some((bundle) => indexPartsOf(bundle).length === 0)) { return null; } /** @param {string} cid @param {number} [start] @param {number} [end] */ const read = async (cid, start, end) => { const bytes = start === undefined || end === undefined ? await getBlob(cid) : await getBlobRange(cid, start, end); if (!bytes) { throw new GitRuleError( `bundle chunk ${cid} is not in blob storage`, 'InvalidRecord', ); } return bytes; }; const store = await createLazyStore( record, (cid) => read(cid), (cid, start, end) => read(cid, start, end), ); const shas = await store.prefetchCommits(); /** @type {Map} */ const objects = new Map(); for (const sha of shas) { const object = store.peek(sha); if (object) objects.set(sha, object); } return objects; } /** * @param {unknown} value - the new repo record, whose chain holds the graph * @param {(cid: string) => Promise} getBlob * @param {((cid: string, start: number, end: number) => Promise)} [getBlobRange] * @returns {Promise>} the * commits and tags the chain holds, by id */ async function loadObjects(value, getBlob, getBlobRange) { const record = parseRepoRecord(value); if (getBlobRange) { const lazy = await loadCommitsLazily(record, getBlob, getBlobRange); if (lazy) return lazy; } /** @type {Uint8Array[]} */ const packs = []; for (const bundle of record.bundles) { /** @type {Uint8Array[]} */ const chunks = []; for (const part of bundle.parts) { const bytes = await getBlob(part.ref.$link); if (!bytes) { throw new GitRuleError( `bundle chunk ${part.ref.$link} is not in blob storage`, 'InvalidRecord', ); } chunks.push(bytes); } packs.push(parseBundle(concatChunks(chunks)).pack); } // The guard walks commit parents, so trees and blobs are needed only as // delta bases while the pack is read. return await indexPacks(packs, undefined, undefined, { retain: 'commits' }); } /** * Whether old is an ancestor of new: walk parents from the new tip. A parent * outside the pack index is a bundle prerequisite, reachable only through * history already accepted, so the walk stops there. * @param {Map} objects * @param {string} oldSha * @param {string} newSha * @returns {boolean} */ function isAncestorOf(objects, oldSha, newSha) { const frontier = [resolveToCommit(objects, newSha)]; const seen = new Set(frontier); let walked = 0; while (frontier.length > 0) { const sha = /** @type {string} */ (frontier.pop()); if (sha === oldSha) return true; if (walked++ > MAX_WALK) return false; const object = objects.get(sha); if (object?.type !== 'commit') continue; for (const parent of parseCommit(object.data).parents) { if (!seen.has(parent)) { seen.add(parent); frontier.push(parent); } } } return false; } /** * @param {unknown} value - a config record value about to be written */ function assertValidConfig(value) { const { protectedBranches: list, packages } = /** @type {{protectedBranches?: unknown, packages?: unknown}} */ ( value ?? {} ); if (list !== undefined) { if ( !Array.isArray(list) || list.some( (branch) => typeof branch !== 'string' || branch.length === 0 || /[\s*?[\\~^:]/.test(branch), ) ) { throw new GitRuleError( 'protectedBranches must be a list of branch names', 'InvalidRecord', ); } } if (packages === undefined) return; // The registry is checked for shape, not against the list this build knows. // A registry added after this server was deployed stays writable, and a // reader that has no words for it drops the entry. if ( !Array.isArray(packages) || packages.some((entry) => { const { registry, name } = /** @type {{registry?: unknown, name?: unknown}} */ (entry ?? {}); return ( typeof registry !== 'string' || registry.length === 0 || typeof name !== 'string' || name.length === 0 || /\s/.test(name) ); }) ) { throw new GitRuleError( 'packages must be a list of {registry, name} entries', 'InvalidRecord', ); } } /** * One write under inspection. Structural rather than imported from core: this * package has no dependency on core, only a shape they agree on. * @typedef {Object} GuardedWrite * @property {string|null} space - space AT-URI, or null for the public repo * @property {string} collection * @property {string} rkey * @property {unknown|null} prevValue - the stored record, null on creation * @property {unknown|null} nextValue - the incoming record, null on deletion */ /** * Build the write guard the platforms hand to the record write paths. * * @param {Object} ctx * @param {(space: string|null, collection: string, rkey: string) => Promise} ctx.getRecord - * a decoded record value from the public repo (space null) or a space * @param {(cid: string) => Promise} ctx.getBlob * @param {(cid: string, start: number, end: number) => Promise} [ctx.getBlobRange] - * Optional: one byte range of a blob, end exclusive. A store that reads * ranges lets the ancestry check read commits out of the chain rather * than downloading it whole. * @returns {{collections: string[], check: (write: GuardedWrite) => Promise}} */ export function createGitWriteGuard({ getRecord, getBlob, getBlobRange }) { return { collections: ['dev.pdsjs.git.repo', GIT_CONFIG_COLLECTION], async check({ space, collection, rkey, prevValue, nextValue }) { if (collection === GIT_CONFIG_COLLECTION) { // Deleting the config removes the rules, which their owner may do. if (nextValue !== null) assertValidConfig(nextValue); return; } const config = await getRecord(space, GIT_CONFIG_COLLECTION, rkey); const branches = protectedBranchesOf(config); // No rules, or the repo record's creation: nothing to hold a ref // transition against. if (branches.length === 0 || prevValue === null) return; const prevRefs = refMap(prevValue); if (nextValue === null) { const held = branches.filter((branch) => prevRefs.has(`refs/heads/${branch}`), ); if (held.length > 0) { throw new GitRuleError( `the repository holds protected branches (${held.join(', ')}); remove the protection before deleting it`, ); } return; } const nextRefs = refMap(nextValue); /** @type {Map|null} */ let objects = null; for (const branch of branches) { const ref = `refs/heads/${branch}`; const oldSha = prevRefs.get(ref); if (!oldSha) continue; const newSha = nextRefs.get(ref); if (!newSha) { throw new GitRuleError( `${branch} is protected and cannot be deleted`, ); } if (newSha === oldSha) continue; objects ??= await loadObjects(nextValue, getBlob, getBlobRange); if (!isAncestorOf(objects, oldSha, newSha)) { throw new GitRuleError( `${branch} is protected; only fast-forward pushes are accepted`, ); } } }, }; }