erasure.ts

10.3 KB · raw

1/**2 * erasure.ts — Reed-Solomon erasure coding around the WASM library.3 *4 * The library wants a single contiguous buffer of length5 *   (dataShards + parityShards) * shardSize6 * with data shards laid out first, followed by space for parity. `encode()`7 * fills the parity region in-place. `reconstruct()` rewrites missing shards8 * in-place using a `shardsAvailable: boolean[]` mask.9 *10 * We expose a chunk-oriented API (`encodeWithErasure` / `decodeWithErasure`)11 * because the rest of the codex pipeline thinks in terms of individual12 * envelopes. Both adapters allocate a fresh buffer per call — the library is13 * not re-entrant on a shared buffer.14 */1516import { ReedSolomonErasure } from "@subspace/reed-solomon-erasure.wasm";17import {18  CHUNK_SIZE,19  CodexError,20  computeParityShards,21} from "../../shared/codex-types";22import { getReedSolomon } from "./rs-loader";2324/**25 * The shared rs-loader caches a single `ReedSolomonErasure` instance. Some26 * builds of the underlying WASM module accumulate __wbindgen state between27 * `reconstruct()` calls and return RESULT_ERROR_TOO_FEW_SHARDS_PRESENT on the28 * second invocation even when enough shards are present. Building a fresh29 * instance per call avoids the failure mode entirely; the cost is a few30 * milliseconds per call — negligible compared to compressing the vault and31 * rendering Aztec codes. We keep the loader's cached instance only as a32 * liveness probe (it confirms the WASM is instantiable on this platform).33 */34let cachedWasmBytes: Uint8Array<ArrayBuffer> | null = null;3536async function freshReedSolomon(): Promise<ReedSolomonErasure> {37  if (cachedWasmBytes) {38    return ReedSolomonErasure.fromBytes(cachedWasmBytes);39  }40  // Capture bytes by intercepting the loader's first instantiation. The41  // loader caches its own promise, so calling it twice is cheap on the second42  // pass. We pull bytes from the WASM memory image after the fact — see43  // `loadWasmBytes` below for the lazy initialization.44  await getReedSolomon();45  if (!cachedWasmBytes) {46    cachedWasmBytes = await loadWasmBytes();47  }48  return ReedSolomonErasure.fromBytes(cachedWasmBytes);49}5051/**52 * Load the raw WASM bytes for re-instantiation. The popup path resolves53 * against the extension package via `chrome.runtime.getURL`; vitest reads54 * directly from `node_modules`. `freshReedSolomon` needs the raw bytes (not55 * the loader's instance) because each call rebuilds the WASM module to avoid56 * cross-call state accumulation in `reconstruct`.57 */58async function loadWasmBytes(): Promise<Uint8Array<ArrayBuffer>> {59  const inPopup =60    typeof chrome !== "undefined" &&61    typeof chrome.runtime !== "undefined" &&62    typeof chrome.runtime.getURL === "function";63  if (inPopup) {64    const url = chrome.runtime.getURL("codex/reed_solomon_erasure_bg.wasm");65    const response = await fetch(url);66    if (!response.ok) {67      throw new CodexError(68        "ERASURE_CODING_VERIFICATION_FAILED",69        `Failed to fetch WASM: ${response.status}`,70      );71    }72    return new Uint8Array(await response.arrayBuffer());73  }74  const fs = await import("node:fs");75  const path = await import("node:path");76  const wasmPath = path.resolve(77    "node_modules/@subspace/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm",78  );79  return new Uint8Array(fs.readFileSync(wasmPath));80}8182/**83 * @subspace/reed-solomon-erasure.wasm exposes the RESULT_* constants but not a84 * `getResultMessage(code)` helper (the @digitaldefiance fork added it). We map85 * codes to human-readable strings here so the surrounding CodexError messages86 * stay specific instead of leaking a bare number.87 */88function resultMessage(code: number): string {89  switch (code) {90    case ReedSolomonErasure.RESULT_OK: return "ok";91    case ReedSolomonErasure.RESULT_ERROR_TOO_FEW_SHARDS: return "too few shards";92    case ReedSolomonErasure.RESULT_ERROR_TOO_MANY_SHARDS: return "too many shards";93    case ReedSolomonErasure.RESULT_ERROR_TOO_FEW_DATA_SHARDS: return "too few data shards";94    case ReedSolomonErasure.RESULT_ERROR_TOO_MANY_DATA_SHARDS: return "too many data shards";95    case ReedSolomonErasure.RESULT_ERROR_TOO_FEW_PARITY_SHARDS: return "too few parity shards";96    case ReedSolomonErasure.RESULT_ERROR_TOO_MANY_PARITY_SHARDS: return "too many parity shards";97    case ReedSolomonErasure.RESULT_ERROR_TOO_FEW_BUFFER_SHARDS: return "too few buffer shards";98    case ReedSolomonErasure.RESULT_ERROR_TOO_MANY_BUFFER_SHARDS: return "too many buffer shards";99    case ReedSolomonErasure.RESULT_ERROR_INCORRECT_SHARD_SIZE: return "incorrect shard size";100    case ReedSolomonErasure.RESULT_ERROR_TOO_FEW_SHARDS_PRESENT: return "too few shards present";101    case ReedSolomonErasure.RESULT_ERROR_EMPTY_SHARD: return "empty shard";102    case ReedSolomonErasure.RESULT_ERROR_INVALID_SHARD_FLAGS: return "invalid shard flags";103    case ReedSolomonErasure.RESULT_ERROR_INVALID_INDEX: return "invalid index";104    default: return `unknown result code ${code}`;105  }106}107108export interface EncodeResult {109  dataChunks: Uint8Array[];110  parityChunks: Uint8Array[];111  totalChunks: number;112  dataShards: number;113  parityShards: number;114}115116/**117 * Split `data` into fixed-size shards plus `computeParityShards(dataShards)`118 * parity shards. Pads the trailing data shard with zeros — the original byte119 * length is recovered via the `CodexPayload.vaultBlob` (no length prefix120 * needed on the wire because the outer JSON already bounds the data).121 */122export async function encodeWithErasure(data: Uint8Array): Promise<EncodeResult> {123  const rs = await freshReedSolomon();124125  const dataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));126  const parityShards = computeParityShards(dataShards);127  const totalShards = dataShards + parityShards;128  const buffer = new Uint8Array(totalShards * CHUNK_SIZE);129  buffer.set(data, 0); // Trailing bytes after `data.length` stay zero — the pad.130131  let code: number;132  try {133    code = rs.encode(buffer, dataShards, parityShards);134  } catch (err) {135    // WASM traps (e.g. Rust panics) surface as raw JS errors with messages136    // like "unreachable". Map them to a typed CodexError so the UI can137    // explain what happened instead of leaking the trap string.138    throw new CodexError(139      "ERASURE_CODING_VERIFICATION_FAILED",140      `Reed-Solomon encode trapped (data=${dataShards}, parity=${parityShards}): ${141        err instanceof Error ? err.message : String(err)142      }`,143    );144  }145  if (code !== ReedSolomonErasure.RESULT_OK) {146    throw new CodexError(147      "ERASURE_CODING_VERIFICATION_FAILED",148      `Reed-Solomon encode failed: ${resultMessage(code)}`,149    );150  }151152  // Slice() copies so each shard owns its memory and callers cannot mutate153  // the working buffer through a returned view.154  const dataChunks: Uint8Array[] = [];155  for (let i = 0; i < dataShards; i++) {156    dataChunks.push(buffer.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));157  }158  const parityChunks: Uint8Array[] = [];159  for (let i = 0; i < parityShards; i++) {160    const base = (dataShards + i) * CHUNK_SIZE;161    parityChunks.push(buffer.slice(base, base + CHUNK_SIZE));162  }163164  return {165    dataChunks,166    parityChunks,167    totalChunks: totalShards,168    dataShards,169    parityShards,170  };171}172173/**174 * Reassemble the data-shard region from a partial set of chunks. Missing175 * shards are reconstructed from parity. The returned buffer is exactly176 * `dataShards * shardSize` bytes long, including any trailing pad bytes177 * — callers strip the pad using their out-of-band length information.178 *179 * Shard size is derived from the actual chunks rather than the current180 * `CHUNK_SIZE` constant so that Codexes printed under an older constant181 * (e.g. 1024-byte shards) still restore on a build whose constant has moved.182 * Reed-Solomon requires all shards to share one length; the first chunk183 * establishes it and every other chunk must match.184 */185export async function decodeWithErasure(186  chunks: Map<number, Uint8Array>,187  dataShards: number,188  totalShards: number,189): Promise<Uint8Array> {190  const parityShards = totalShards - dataShards;191  if (parityShards < 0) {192    throw new CodexError(193      "ERASURE_CODING_VERIFICATION_FAILED",194      `Invalid shard layout: dataShards=${dataShards} totalShards=${totalShards}`,195    );196  }197198  if (chunks.size < dataShards) {199    throw new CodexError(200      "INSUFFICIENT_CHUNKS",201      `Need at least ${dataShards} chunks, have ${chunks.size}`,202    );203  }204205  // First scanned chunk fixes the shard size for the whole reconstruction.206  const firstChunk = chunks.values().next().value;207  if (!firstChunk || firstChunk.length === 0) {208    throw new CodexError(209      "ERASURE_CODING_VERIFICATION_FAILED",210      "First chunk is empty; cannot determine shard size",211    );212  }213  const shardSize = firstChunk.length;214215  const rs = await freshReedSolomon();216  const buffer = new Uint8Array(totalShards * shardSize);217  const available: boolean[] = new Array(totalShards).fill(false);218219  for (const [index, chunk] of chunks) {220    if (index < 0 || index >= totalShards) {221      throw new CodexError(222        "ERASURE_CODING_VERIFICATION_FAILED",223        `Chunk index ${index} out of range [0, ${totalShards})`,224      );225    }226    if (chunk.length !== shardSize) {227      throw new CodexError(228        "ERASURE_CODING_VERIFICATION_FAILED",229        `Chunk ${index} has length ${chunk.length}, expected ${shardSize}`,230      );231    }232    buffer.set(chunk, index * shardSize);233    available[index] = true;234  }235236  let code: number;237  try {238    code = rs.reconstruct(buffer, dataShards, parityShards, available);239  } catch (err) {240    throw new CodexError(241      "ERASURE_CODING_VERIFICATION_FAILED",242      `Reed-Solomon reconstruct trapped (data=${dataShards}, parity=${parityShards}, have=${chunks.size}): ${243        err instanceof Error ? err.message : String(err)244      }`,245    );246  }247  if (code !== ReedSolomonErasure.RESULT_OK) {248    // The library distinguishes "not enough shards" with its own code; map it249    // back to our INSUFFICIENT_CHUNKS so handlers upstream can surface a250    // clean "scan more codes" prompt instead of a generic library error.251    if (code === ReedSolomonErasure.RESULT_ERROR_TOO_FEW_SHARDS_PRESENT) {252      throw new CodexError(253        "INSUFFICIENT_CHUNKS",254        `Need at least ${dataShards} valid chunks for reconstruction`,255      );256    }257    throw new CodexError(258      "ERASURE_CODING_VERIFICATION_FAILED",259      `Reed-Solomon reconstruct failed: ${resultMessage(code)}`,260    );261  }262263  return buffer.slice(0, dataShards * shardSize);264}