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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
217 lines · 5.8 KB Raw History
// @pdsjs/core/car - CAR (Content Addressable aRchive) file parser
// Parses CAR v1 files for importing AT Protocol repositories

import { cborDecode, cidToString } from './repo.js';

/**
 * Read an unsigned varint from bytes at the given offset
 * @param {Uint8Array} bytes - Input bytes
 * @param {number} offset - Starting offset
 * @returns {[number, number]} - [decoded value, new offset after varint]
 */
export function readVarint(bytes, offset) {
  let value = 0;
  let shift = 0;
  let pos = offset;

  while (pos < bytes.length) {
    const byte = bytes[pos++];
    value |= (byte & 0x7f) << shift;
    if ((byte & 0x80) === 0) {
      return [value, pos];
    }
    shift += 7;
    if (shift > 35) {
      throw new Error('Varint too long');
    }
  }

  throw new Error('Unexpected end of varint');
}

/**
 * Parse a CID from raw bytes
 * Handles CIDv0 (starts with 0x12) and CIDv1 (starts with 0x01)
 * @param {Uint8Array} bytes - CID bytes
 * @param {number} offset - Starting offset
 * @returns {[Uint8Array, number]} - [CID bytes, new offset]
 */
function parseCidFromBytes(bytes, offset) {
  const firstByte = bytes[offset];

  if (firstByte === 0x12) {
    // CIDv0: SHA-256 multihash (0x12 = sha2-256, 0x20 = 32 bytes)
    // Total: 34 bytes
    const cidLen = 34;
    return [bytes.slice(offset, offset + cidLen), offset + cidLen];
  }

  // CIDv1: version + codec + multihash
  let pos = offset;

  // Read version (should be 1)
  const [version, afterVersion] = readVarint(bytes, pos);
  if (version !== 1) {
    throw new Error(`Unsupported CID version: ${version}`);
  }
  pos = afterVersion;

  // Read codec
  const [_codec, afterCodec] = readVarint(bytes, pos);
  pos = afterCodec;

  // Read multihash: hash function + digest length + digest
  const [_hashFn, afterHashFn] = readVarint(bytes, pos);
  pos = afterHashFn;

  const [digestLen, afterDigestLen] = readVarint(bytes, pos);
  pos = afterDigestLen + digestLen;

  return [bytes.slice(offset, pos), pos];
}

/**
 * @typedef {Object} CarBlock
 * @property {string} cid - Block CID as base32lower string
 * @property {Uint8Array} data - Block data bytes
 */

/**
 * @typedef {Object} ParsedCar
 * @property {string[]} roots - Root CID strings
 * @property {Map<string, Uint8Array>} blocks - Map of CID string to block data
 */

/**
 * Parse a complete CAR file
 * @param {Uint8Array} carBytes - Complete CAR file bytes
 * @returns {ParsedCar} - Parsed CAR with roots and blocks
 */
export function parseCarFile(carBytes) {
  let offset = 0;

  // Read header length (varint)
  const [headerLen, afterHeaderLen] = readVarint(carBytes, offset);
  offset = afterHeaderLen;

  // Decode header (CBOR)
  const headerBytes = carBytes.slice(offset, offset + headerLen);
  const header = cborDecode(headerBytes);
  offset += headerLen;

  // Validate header
  if (header.version !== 1) {
    throw new Error(`Unsupported CAR version: ${header.version}`);
  }

  // Extract roots (CIDs stored as Uint8Array from DAG-CBOR tag 42)
  const roots = (header.roots || []).map(
    (/** @type {Uint8Array} */ cidBytes) => {
      return cidToString(cidBytes);
    },
  );

  // Parse blocks
  /** @type {Map<string, Uint8Array>} */
  const blocks = new Map();

  while (offset < carBytes.length) {
    // Read block length (varint)
    const [blockLen, afterBlockLen] = readVarint(carBytes, offset);
    offset = afterBlockLen;

    if (blockLen === 0) break;

    // Parse CID
    const [cidBytes, afterCid] = parseCidFromBytes(carBytes, offset);
    const cid = cidToString(cidBytes);

    // Extract block data
    const dataLen = blockLen - (afterCid - offset);
    const data = carBytes.slice(afterCid, afterCid + dataLen);

    blocks.set(cid, data);
    offset = afterCid + dataLen;
  }

  return { roots, blocks };
}

/**
 * Iterate over CAR file blocks (memory-efficient streaming)
 * @param {Uint8Array} carBytes - Complete CAR file bytes
 * @yields {CarBlock} - Block with CID and data
 * @returns {Generator<CarBlock, {roots: string[]}, unknown>}
 */
export function* iterateCarBlocks(carBytes) {
  let offset = 0;

  // Read header length (varint)
  const [headerLen, afterHeaderLen] = readVarint(carBytes, offset);
  offset = afterHeaderLen;

  // Decode header (CBOR)
  const headerBytes = carBytes.slice(offset, offset + headerLen);
  const header = cborDecode(headerBytes);
  offset += headerLen;

  // Validate header
  if (header.version !== 1) {
    throw new Error(`Unsupported CAR version: ${header.version}`);
  }

  // Extract roots for return value
  const roots = (header.roots || []).map(
    (/** @type {Uint8Array} */ cidBytes) => {
      return cidToString(cidBytes);
    },
  );

  // Iterate blocks
  while (offset < carBytes.length) {
    // Read block length (varint)
    const [blockLen, afterBlockLen] = readVarint(carBytes, offset);
    offset = afterBlockLen;

    if (blockLen === 0) break;

    // Parse CID
    const [cidBytes, afterCid] = parseCidFromBytes(carBytes, offset);
    const cid = cidToString(cidBytes);

    // Extract block data
    const dataLen = blockLen - (afterCid - offset);
    const data = carBytes.slice(afterCid, afterCid + dataLen);

    yield { cid, data };
    offset = afterCid + dataLen;
  }

  return { roots };
}

/**
 * Get CAR file header (roots) without parsing all blocks
 * @param {Uint8Array} carBytes - CAR file bytes
 * @returns {{ version: number, roots: string[] }}
 */
export function getCarHeader(carBytes) {
  let offset = 0;

  // Read header length (varint)
  const [headerLen, afterHeaderLen] = readVarint(carBytes, offset);
  offset = afterHeaderLen;

  // Decode header (CBOR)
  const headerBytes = carBytes.slice(offset, offset + headerLen);
  const header = cborDecode(headerBytes);

  // Extract roots
  const roots = (header.roots || []).map(
    (/** @type {Uint8Array} */ cidBytes) => {
      return cidToString(cidBytes);
    },
  );

  return { version: header.version, roots };
}