erasure.ts

8.9 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 "@digitaldefiance/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, which26 * is fine for `encode()` but trips a known library bug on the SECOND call to27 * `reconstruct()` against the same instance: the internal __wbindgen state28 * survives between calls and the second reconstruction reports29 * RESULT_ERROR_TOO_FEW_SHARDS_PRESENT even when enough shards are present.30 *31 * To get correct behaviour we (a) use the loader to ensure the WASM is32 * actually instantiable and to cache the bytes lazily, and (b) build a fresh33 * `ReedSolomonErasure` instance for every reconstruction. The cost is a few34 * milliseconds per call — negligible compared to compressing the vault and35 * rendering Aztec codes.36 */37let cachedWasmBytes: Uint8Array | null = null;3839async function freshReedSolomon(): Promise<ReedSolomonErasure> {40  if (cachedWasmBytes) {41    return ReedSolomonErasure.fromBytes(cachedWasmBytes);42  }43  // Capture bytes by intercepting the loader's first instantiation. The44  // loader caches its own promise, so calling it twice is cheap on the second45  // pass. We pull bytes from the WASM memory image after the fact — see46  // `loadWasmBytes` below for the lazy initialization.47  await getReedSolomon();48  if (!cachedWasmBytes) {49    cachedWasmBytes = await loadWasmBytes();50  }51  return ReedSolomonErasure.fromBytes(cachedWasmBytes);52}5354/**55 * Load the WASM bytes through the same probing logic rs-loader uses, so this56 * file does not assume any specific filesystem layout. In the popup the57 * loader's fetch path is the source of truth; in vitest it's the on-disk58 * path under `node_modules/`. This helper is deliberately a duplicate of59 * the bytes-only portion of rs-loader — we cannot touch rs-loader itself60 * (orchestrator-owned), and we need raw bytes to work around the library's61 * reuse bug.62 */63async function loadWasmBytes(): Promise<Uint8Array> {64  const inPopup =65    typeof chrome !== "undefined" &&66    typeof chrome.runtime !== "undefined" &&67    typeof chrome.runtime.getURL === "function";68  if (inPopup) {69    const url = chrome.runtime.getURL("codex/reed_solomon_erasure_bg.wasm");70    const response = await fetch(url);71    if (!response.ok) {72      throw new CodexError(73        "ERASURE_CODING_VERIFICATION_FAILED",74        `Failed to fetch WASM: ${response.status}`,75      );76    }77    return new Uint8Array(await response.arrayBuffer());78  }79  const fs = await import("node:fs");80  const path = await import("node:path");81  const wasmPath = path.resolve(82    "node_modules/@digitaldefiance/reed-solomon-erasure.wasm/dist/reed_solomon_erasure_bg.wasm",83  );84  return new Uint8Array(fs.readFileSync(wasmPath));85}8687export interface EncodeResult {88  dataChunks: Uint8Array[];89  parityChunks: Uint8Array[];90  totalChunks: number;91  dataShards: number;92  parityShards: number;93}9495/**96 * Split `data` into fixed-size shards plus `computeParityShards(dataShards)`97 * parity shards. Pads the trailing data shard with zeros — the original byte98 * length is recovered via the `CodexPayload.vaultBlob` (no length prefix99 * needed on the wire because the outer JSON already bounds the data).100 */101export async function encodeWithErasure(data: Uint8Array): Promise<EncodeResult> {102  const rs = await freshReedSolomon();103104  const dataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));105  const parityShards = computeParityShards(dataShards);106  const totalShards = dataShards + parityShards;107  const buffer = new Uint8Array(totalShards * CHUNK_SIZE);108  buffer.set(data, 0); // Trailing bytes after `data.length` stay zero — the pad.109110  let code: number;111  try {112    code = rs.encode(buffer, dataShards, parityShards);113  } catch (err) {114    // WASM traps (e.g. Rust panics) surface as raw JS errors with messages115    // like "unreachable". Map them to a typed CodexError so the UI can116    // explain what happened instead of leaking the trap string.117    throw new CodexError(118      "ERASURE_CODING_VERIFICATION_FAILED",119      `Reed-Solomon encode trapped (data=${dataShards}, parity=${parityShards}): ${120        err instanceof Error ? err.message : String(err)121      }`,122    );123  }124  if (code !== ReedSolomonErasure.RESULT_OK) {125    throw new CodexError(126      "ERASURE_CODING_VERIFICATION_FAILED",127      `Reed-Solomon encode failed: ${ReedSolomonErasure.getResultMessage(code)}`,128    );129  }130131  // Slice() copies so each shard owns its memory and callers cannot mutate132  // the working buffer through a returned view.133  const dataChunks: Uint8Array[] = [];134  for (let i = 0; i < dataShards; i++) {135    dataChunks.push(buffer.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));136  }137  const parityChunks: Uint8Array[] = [];138  for (let i = 0; i < parityShards; i++) {139    const base = (dataShards + i) * CHUNK_SIZE;140    parityChunks.push(buffer.slice(base, base + CHUNK_SIZE));141  }142143  return {144    dataChunks,145    parityChunks,146    totalChunks: totalShards,147    dataShards,148    parityShards,149  };150}151152/**153 * Reassemble the data-shard region from a partial set of chunks. Missing154 * shards are reconstructed from parity. The returned buffer is exactly155 * `dataShards * shardSize` bytes long, including any trailing pad bytes156 * — callers strip the pad using their out-of-band length information.157 *158 * Shard size is derived from the actual chunks rather than the current159 * `CHUNK_SIZE` constant so that Codexes printed under an older constant160 * (e.g. 1024-byte shards) still restore on a build whose constant has moved.161 * Reed-Solomon requires all shards to share one length; the first chunk162 * establishes it and every other chunk must match.163 */164export async function decodeWithErasure(165  chunks: Map<number, Uint8Array>,166  dataShards: number,167  totalShards: number,168): Promise<Uint8Array> {169  const parityShards = totalShards - dataShards;170  if (parityShards < 0) {171    throw new CodexError(172      "ERASURE_CODING_VERIFICATION_FAILED",173      `Invalid shard layout: dataShards=${dataShards} totalShards=${totalShards}`,174    );175  }176177  if (chunks.size < dataShards) {178    throw new CodexError(179      "INSUFFICIENT_CHUNKS",180      `Need at least ${dataShards} chunks, have ${chunks.size}`,181    );182  }183184  // First scanned chunk fixes the shard size for the whole reconstruction.185  const firstChunk = chunks.values().next().value;186  if (!firstChunk || firstChunk.length === 0) {187    throw new CodexError(188      "ERASURE_CODING_VERIFICATION_FAILED",189      "First chunk is empty; cannot determine shard size",190    );191  }192  const shardSize = firstChunk.length;193194  const rs = await freshReedSolomon();195  const buffer = new Uint8Array(totalShards * shardSize);196  const available: boolean[] = new Array(totalShards).fill(false);197198  for (const [index, chunk] of chunks) {199    if (index < 0 || index >= totalShards) {200      throw new CodexError(201        "ERASURE_CODING_VERIFICATION_FAILED",202        `Chunk index ${index} out of range [0, ${totalShards})`,203      );204    }205    if (chunk.length !== shardSize) {206      throw new CodexError(207        "ERASURE_CODING_VERIFICATION_FAILED",208        `Chunk ${index} has length ${chunk.length}, expected ${shardSize}`,209      );210    }211    buffer.set(chunk, index * shardSize);212    available[index] = true;213  }214215  let code: number;216  try {217    code = rs.reconstruct(buffer, dataShards, parityShards, available);218  } catch (err) {219    throw new CodexError(220      "ERASURE_CODING_VERIFICATION_FAILED",221      `Reed-Solomon reconstruct trapped (data=${dataShards}, parity=${parityShards}, have=${chunks.size}): ${222        err instanceof Error ? err.message : String(err)223      }`,224    );225  }226  if (code !== ReedSolomonErasure.RESULT_OK) {227    // The library distinguishes "not enough shards" with its own code; map it228    // back to our INSUFFICIENT_CHUNKS so handlers upstream can surface a229    // clean "scan more codes" prompt instead of a generic library error.230    if (code === ReedSolomonErasure.RESULT_ERROR_TOO_FEW_SHARDS_PRESENT) {231      throw new CodexError(232        "INSUFFICIENT_CHUNKS",233        `Need at least ${dataShards} valid chunks for reconstruction`,234      );235    }236    throw new CodexError(237      "ERASURE_CODING_VERIFICATION_FAILED",238      `Reed-Solomon reconstruct failed: ${ReedSolomonErasure.getResultMessage(code)}`,239    );240  }241242  return buffer.slice(0, dataShards * shardSize);243}