~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
415 lines · 14.3 KB Raw History
/**
 * 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<string, string>} 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<Uint8Array|null>} getBlob
 * @param {(cid: string, start: number, end: number) => Promise<Uint8Array|null>} getBlobRange
 * @returns {Promise<Map<string, import('./objects.js').GitObject>|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<string, import('./objects.js').GitObject>} */
  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<Uint8Array|null>} getBlob
 * @param {((cid: string, start: number, end: number) => Promise<Uint8Array|null>)} [getBlobRange]
 * @returns {Promise<Map<string, import('./objects.js').GitObject>>} 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<string, import('./objects.js').GitObject>} 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<unknown|null>} ctx.getRecord -
 *   a decoded record value from the public repo (space null) or a space
 * @param {(cid: string) => Promise<Uint8Array|null>} ctx.getBlob
 * @param {(cid: string, start: number, end: number) => Promise<Uint8Array|null>} [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<void>}}
 */
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<string, import('./objects.js').GitObject>|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`,
          );
        }
      }
    },
  };
}