OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / services / follow-payload.js

249 lines · javascript ref:
/**
 * follow-payload — Discovery payload for non-mirror follow.
 *
 * Compact-encoded blob (b64url-wrapped for CLI/URL transport) that
 * gives a non-mirror daemon everything it needs to read-only replicate
 * one remote resource (a repo or an org).
 *
 * See spec/modules/follow-payload.md for the schema + auth model.
 *
 * Nullable buffer fields use the same 1-byte presence prefix + 32-byte
 * payload pattern as repo-event-log-messages.js.
 */

import c from 'compact-encoding'
import b4a from 'b4a'

export const VERSION = 1

export const KIND_REPO = 1
export const KIND_ORG  = 2

const DEFAULT_TOPIC_SALT = 'pear-git-v3'

// ── Helpers ──────────────────────────────────────────────────────

function toBuf32 (v, field) {
  if (v == null) return null
  // A 32-byte value is exactly 64 hex chars. Validate the string shape BEFORE
  // parsing: bare's b4a.from throws "Invalid input" on malformed hex (node is
  // lenient and returns a short buffer), so without this guard the intended
  // length error is masked by a low-level parse error under bare.
  if (typeof v === 'string' && !/^[0-9a-fA-F]{64}$/.test(v)) {
    const err = new Error(`${field} must be 32 bytes (got ${Math.floor(v.length / 2)})`)
    err.code = 'EBADFIELD'
    throw err
  }
  const buf = typeof v === 'string' ? b4a.from(v, 'hex') : v
  if (buf.length !== 32) {
    const err = new Error(`${field} must be 32 bytes (got ${buf.length})`)
    err.code = 'EBADFIELD'
    throw err
  }
  return buf
}

function require32 (v, field) {
  const buf = toBuf32(v, field)
  if (!buf) {
    const err = new Error(`${field} is required`)
    err.code = 'EREQUIRED'
    throw err
  }
  return buf
}

function preencodeNullableBuf (state, buf) {
  c.uint8.preencode(state, buf ? 1 : 0)
  if (buf) state.end += 32
}

function encodeNullableBuf (state, buf) {
  c.uint8.encode(state, buf ? 1 : 0)
  if (buf) {
    buf.copy(state.buffer, state.start, 0, 32)
    state.start += 32
  }
}

function decodeNullableBuf (state) {
  const present = c.uint8.decode(state)
  if (!present) return null
  const out = b4a.from(state.buffer.subarray(state.start, state.start + 32))
  state.start += 32
  return out
}

// ── Codec ───────────────────────────────────────────────────────

function preencodePayload (state, m) {
  c.uint8.preencode(state, m.version)
  c.uint8.preencode(state, m.kindNum)
  state.end += 32                          // sourceServerKey
  c.string.preencode(state, m.topicSalt)
  c.string.preencode(state, m.name)

  if (m.kindNum === KIND_REPO) {
    preencodeNullableBuf(state, m.issueBootstrapKey)
    preencodeNullableBuf(state, m.repoEventLogKey)
  } else {
    state.end += 32                        // orgsBootstrapKey
    state.end += 32                        // orgEventLogKey
    preencodeNullableBuf(state, m.orgIssueBootstrapKey)
    preencodeNullableBuf(state, m.repoEventLogKey)
  }
}

function encodePayload (state, m) {
  c.uint8.encode(state, m.version)
  c.uint8.encode(state, m.kindNum)
  m.sourceServerKey.copy(state.buffer, state.start, 0, 32); state.start += 32
  c.string.encode(state, m.topicSalt)
  c.string.encode(state, m.name)

  if (m.kindNum === KIND_REPO) {
    encodeNullableBuf(state, m.issueBootstrapKey)
    encodeNullableBuf(state, m.repoEventLogKey)
  } else {
    m.orgsBootstrapKey.copy(state.buffer, state.start, 0, 32); state.start += 32
    m.orgEventLogKey.copy(state.buffer, state.start, 0, 32); state.start += 32
    encodeNullableBuf(state, m.orgIssueBootstrapKey)
    encodeNullableBuf(state, m.repoEventLogKey)
  }
}

function decodePayload (state) {
  const version = c.uint8.decode(state)
  if (version !== VERSION) {
    const err = new Error(`unknown follow-payload version ${version}`)
    err.code = 'EBADVERSION'
    throw err
  }
  const kindNum = c.uint8.decode(state)
  if (kindNum !== KIND_REPO && kindNum !== KIND_ORG) {
    const err = new Error(`unknown follow-payload kind ${kindNum}`)
    err.code = 'EBADKIND'
    throw err
  }
  const sourceServerKey = b4a.from(state.buffer.subarray(state.start, state.start + 32))
  state.start += 32
  const topicSalt = c.string.decode(state)
  const name = c.string.decode(state)

  const out = {
    version,
    kind: kindNum === KIND_REPO ? 'repo' : 'org',
    sourceServerKey: b4a.toString(sourceServerKey, 'hex'),
    topicSalt,
    name
  }

  if (kindNum === KIND_REPO) {
    const ibk = decodeNullableBuf(state)
    const relk = decodeNullableBuf(state)
    out.issueBootstrapKey = ibk && b4a.toString(ibk, 'hex')
    out.repoEventLogKey   = relk && b4a.toString(relk, 'hex')
  } else {
    const obk = b4a.from(state.buffer.subarray(state.start, state.start + 32))
    state.start += 32
    const oelk = b4a.from(state.buffer.subarray(state.start, state.start + 32))
    state.start += 32
    const oibk = decodeNullableBuf(state)
    const relk = decodeNullableBuf(state)
    out.orgsBootstrapKey     = b4a.toString(obk,  'hex')
    out.orgEventLogKey       = b4a.toString(oelk, 'hex')
    out.orgIssueBootstrapKey = oibk && b4a.toString(oibk, 'hex')
    out.repoEventLogKey      = relk && b4a.toString(relk, 'hex')
  }

  return out
}

// ── Public API ──────────────────────────────────────────────────

export function encode (input) {
  if (!input || (input.kind !== 'repo' && input.kind !== 'org')) {
    const err = new Error('kind must be "repo" or "org"')
    err.code = 'EBADKIND'
    throw err
  }
  if (!input.name || typeof input.name !== 'string') {
    const err = new Error('name is required')
    err.code = 'EREQUIRED'
    throw err
  }

  const m = {
    version: VERSION,
    kindNum: input.kind === 'repo' ? KIND_REPO : KIND_ORG,
    sourceServerKey: require32(input.sourceServerKey, 'sourceServerKey'),
    topicSalt: input.topicSalt || DEFAULT_TOPIC_SALT,
    name: input.name
  }

  if (input.kind === 'repo') {
    m.issueBootstrapKey = toBuf32(input.issueBootstrapKey, 'issueBootstrapKey')
    m.repoEventLogKey   = toBuf32(input.repoEventLogKey,   'repoEventLogKey')
  } else {
    m.orgsBootstrapKey     = require32(input.orgsBootstrapKey, 'orgsBootstrapKey')
    m.orgEventLogKey       = require32(input.orgEventLogKey,   'orgEventLogKey')
    m.orgIssueBootstrapKey = toBuf32(input.orgIssueBootstrapKey, 'orgIssueBootstrapKey')
    m.repoEventLogKey      = toBuf32(input.repoEventLogKey,      'repoEventLogKey')
  }

  const state = { start: 0, end: 0, buffer: null }
  preencodePayload(state, m)
  state.buffer = b4a.allocUnsafe(state.end)
  state.start = 0
  encodePayload(state, m)
  return state.buffer
}

export function decode (buf) {
  const state = { start: 0, end: buf.length, buffer: buf }
  return decodePayload(state)
}

export function encodeB64 (input) {
  return b4a.toString(encode(input), 'base64url')
}

export function decodeB64 (s) {
  return decode(b4a.from(s, 'base64url'))
}

// ── URL form ─────────────────────────────────────────────────────

const URL_RE = /^punch\+follow:\/\/([0-9a-f]{64})\/(repo|org)\/(.+)$/i

export function formatUrl (payloadOrFields) {
  const kind = payloadOrFields.kind
  const srv  = payloadOrFields.sourceServerKey
  const name = payloadOrFields.name
  if (!kind || !srv || !name) {
    const err = new Error('formatUrl needs { kind, sourceServerKey, name }')
    err.code = 'EREQUIRED'
    throw err
  }
  if (kind !== 'repo' && kind !== 'org') {
    const err = new Error('kind must be "repo" or "org"')
    err.code = 'EBADKIND'
    throw err
  }
  return `punch+follow://${srv}/${kind}/${encodeURIComponent(name)}`
}

export function parseUrl (url) {
  const m = URL_RE.exec(String(url || ''))
  if (!m) {
    const err = new Error('not a punch+follow:// URL')
    err.code = 'EBADURL'
    throw err
  }
  return {
    kind: m[2].toLowerCase(),
    sourceServerKey: m[1].toLowerCase(),
    name: decodeURIComponent(m[3])
  }
}