OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-repo / lib / repo.js

110 lines · javascript ref:
// atproto repo — READ path (slice 1).
//
// A repo is a CAR whose root is a signed commit; the commit's `data` CID points at
// the root of a Merkle Search Tree (MST) of records. This module reads a CAR, walks
// the MST, and — most importantly — VERIFIES byte-exact fidelity by re-encoding every
// block and checking its CID. Writing/mutating the MST is slice 2.

import * as dagCbor from '@ipld/dag-cbor'
import { cidForBytes, cidForBytesSync } from './cid.js'
import { buildMst, reachableBlocks } from './mst.js'
import { unsignedCommit, signCommit, commitCid } from './commit.js'
import { readCar, writeCar } from './car.js'

// CAR read/write moved to the bare-safe minimal codec in ./car.js (was @ipld/car).
export { readCar, writeCar }

export function getBlock (car, cid) {
  const b = car.blocks.get(cid.toString())
  if (!b) throw new Error(`missing block ${cid}`)
  return b
}

export function decodeBlock (car, cid) {
  return dagCbor.decode(getBlock(car, cid).bytes)
}

// The signed commit at the CAR root.
export function loadCommit (car) {
  if (car.roots.length !== 1) throw new Error(`expected 1 root, got ${car.roots.length}`)
  const commit = decodeBlock(car, car.roots[0])
  if (!commit || !commit.did || commit.data === undefined) throw new Error('root is not a commit')
  return { cid: car.roots[0], value: commit }
}

// Walk the MST in key order, yielding { key, value: recordCid }. MST node shape:
//   { l: CID|null, e: [ { p: int, k: bytes, v: CID, t: CID|null } ] }
// keys are prefix-compressed against the previous key in the SAME node.
export function * walkMst (car, rootCid, _prevKey = '') {
  const node = decodeBlock(car, rootCid)
  if (!node || !Array.isArray(node.e)) throw new Error(`not an MST node: ${rootCid}`)
  if (node.l) yield * walkMst(car, node.l)
  let prev = ''
  for (const e of node.e) {
    const suffix = Buffer.from(e.k).toString('utf8')
    const key = prev.slice(0, e.p) + suffix
    prev = key
    yield { key, value: e.v }
    if (e.t) yield * walkMst(car, e.t)
  }
}

// All records as { key, cid, value } in key order.
export function listRecords (car) {
  const commit = loadCommit(car)
  const out = []
  for (const { key, value } of walkMst(car, commit.value.data)) {
    out.push({ key, cid: value, value: decodeBlock(car, value) })
  }
  return out
}

// Byte-exact fidelity check: re-encode every block and confirm its CID is unchanged.
// Also confirms the commit's data CID and every MST value CID resolve to a present
// block. Returns { blocks, records, ok, mismatches }.
export async function verifyRepo (car) {
  const mismatches = []
  for (const [cidStr, { cid, bytes }] of car.blocks) {
    // re-encode: decode then canonical-encode; must reproduce the exact bytes + CID
    const reencoded = dagCbor.encode(dagCbor.decode(bytes))
    const sameBytes = Buffer.from(reencoded).equals(Buffer.from(bytes))
    const recomputed = await cidForBytes(reencoded)
    if (!sameBytes || !recomputed.equals(cid)) {
      mismatches.push({ cid: cidStr, sameBytes, recomputed: recomputed.toString() })
    }
  }
  const commit = loadCommit(car)
  getBlock(car, commit.value.data) // MST root must be present
  const records = listRecords(car)
  for (const r of records) getBlock(car, r.cid) // each record block present
  return { blocks: car.blocks.size, records: records.length, ok: mismatches.length === 0, mismatches }
}

// ---- WRITE path (slice 3) ----

// Originate a full atproto repo from records. Each record is
// { collection, rkey, record } (or { key, value }). Returns the CAR bytes plus the
// commit/data CIDs. `privateKey` is the atproto secp256k1 signing key.
export async function createRepo ({ records, did, rev, privateKey }) {
  const recordBlocks = new Map()
  const mstRecords = []
  for (const r of records) {
    const key = r.key !== undefined ? r.key : `${r.collection}/${r.rkey}`
    const value = r.record !== undefined ? r.record : r.value
    const bytes = dagCbor.encode(value)
    const cid = cidForBytesSync(bytes)
    recordBlocks.set(cid.toString(), { cid, bytes })
    mstRecords.push({ key, value: cid })
  }
  const { root: dataCid, ns } = buildMst(mstRecords)
  const mstBlocks = reachableBlocks(ns, dataCid)

  const signed = signCommit(unsignedCommit({ did, rev, data: dataCid }), privateKey)
  const cCid = commitCid(signed)
  const commitBytes = dagCbor.encode(signed)

  const blocks = [{ cid: cCid, bytes: commitBytes }, ...mstBlocks.values(), ...recordBlocks.values()]
  const carBytes = await writeCar(cCid, blocks)
  return { carBytes, commitCid: cCid, dataCid, signedCommit: signed }
}