package-source.ts

9.0 KB · raw

1#!/usr/bin/env bun2// scripts/package-source.ts3// PB-26-212: ONE source-archive definition for standalone, Chrome+Firefox,4// and Firefox-only release paths. Includes crypto-core, generates5// SOURCE-MANIFEST.json, and produces deterministic (reproducible) ZIPs.67import { createHash } from "node:crypto";8import { crc32, deflateRawSync } from "node:zlib";9import {10  existsSync,11  mkdirSync,12  readFileSync,13  readdirSync,14  writeFileSync,15  type Dirent,16} from "node:fs";17import { basename, join, relative, resolve } from "node:path";18import { fileURLToPath } from "node:url";1920const SCRIPT_DIR = resolve(dirname_safe());21const REPO_ROOT = resolve(SCRIPT_DIR, "..");2223function dirname_safe(): string {24  try {25    return fileURLToPath(new URL(".", import.meta.url));26  } catch {27    return __dirname;28  }29}3031// ─── Exclusions ─────────────────────────────────────────────────────────────3233const EXCLUDE_PATTERNS = [34  /node_modules\//,35  /dist-[^/]+\//,36  /\.zip$/,37  /crypto-core\/target\//,38  /crypto-core\/generated\//,39  /\.DS_Store$/,40  /__MACOSX\//,41];4243function shouldExclude(relPath: string): boolean {44  return EXCLUDE_PATTERNS.some((p) => p.test(relPath));45}4647// ─── File collection ────────────────────────────────────────────────────────4849const INCLUDE_DIRS = ["src", "scripts", "public", "crypto-core"];5051/**52 * The single deliberate third-party file in the source archive (PB-26-913).53 * The shipped extension loads it at runtime and SOURCE-MANIFEST.json pins its54 * digest, so it is allowed through the `node_modules/` exclusion — and only it.55 */56export const REVIEWED_THIRD_PARTY_FILE = "node_modules/pdfjs-dist/build/pdf.worker.min.mjs";5758const INCLUDE_FILES = [59  "fixtures/golden/peach-payload-v4.json",60  "fixtures/golden/sync-entry-envelope-v1.json",61  "manifest.json",62  "manifest.firefox.json",63  "package.json",64  "bun.lock",65  "tsconfig.json",66  "vite.config.ts",67  REVIEWED_THIRD_PARTY_FILE,68];6970function walkDir(dir: string, files: string[]): void {71  let entries: Dirent<string>[];72  try {73    entries = readdirSync(dir, { withFileTypes: true });74  } catch {75    return;76  }77  for (const entry of entries) {78    const fullPath = join(dir, entry.name);79    const relPath = relative(REPO_ROOT, fullPath);80    if (entry.isDirectory()) {81      if (shouldExclude(`${relPath}/`)) continue;82      walkDir(fullPath, files);83    } else if (entry.isFile()) {84      if (shouldExclude(relPath)) continue;85      files.push(relPath);86    }87  }88}8990export function collectSourceFiles(): string[] {91  const files: string[] = [];92  for (const dir of INCLUDE_DIRS) {93    const absDir = join(REPO_ROOT, dir);94    if (existsSync(absDir)) {95      walkDir(absDir, files);96    }97  }98  for (const file of INCLUDE_FILES) {99    if (existsSync(join(REPO_ROOT, file))) {100      files.push(file);101    }102  }103  return [...new Set(files)].sort();104}105106// ─── SOURCE-MANIFEST.json ───────────────────────────────────────────────────107108export interface SourceManifest {109  peachVersion: string;110  noiseWasmSha256: string;111  toolchain: {112    bun: string;113    node: string;114    rust: string;115  };116  files: Array<{ path: string; sha256: string }>;117}118119function sha256(data: Buffer | string): string {120  return createHash("sha256").update(data).digest("hex");121}122123function getNoiseWasmSha256(): string {124  const wasmPath = join(125    REPO_ROOT,126    "src/shared/noise-core-wasm/peach_noise_core_bg.wasm",127  );128  return sha256(readFileSync(wasmPath));129}130131function readToolchain(): { bun: string; node: string; rust: string } {132  const pkg = JSON.parse(133    readFileSync(join(REPO_ROOT, "package.json"), "utf8"),134  );135  const bunVersion = (pkg.packageManager ?? "").replace("bun@", "");136137  const cargoToml = readFileSync(138    join(REPO_ROOT, "crypto-core/Cargo.toml"),139    "utf8",140  );141  const rustMatch = cargoToml.match(/rust-version\s*=\s*"([^"]+)"/);142  const rustVersion = rustMatch ? rustMatch[1] : "unknown";143144  return {145    bun: bunVersion,146    node: pkg.engines?.node ?? "unknown",147    rust: rustVersion,148  };149}150151export function buildSourceManifest(152  files: string[],153  version: string,154): SourceManifest {155  const entries = files.map((path) => ({156    path,157    sha256: sha256(readFileSync(join(REPO_ROOT, path))),158  }));159  return {160    peachVersion: version,161    noiseWasmSha256: getNoiseWasmSha256(),162    toolchain: readToolchain(),163    files: entries,164  };165}166167// ─── Deterministic ZIP writer ───────────────────────────────────────────────168// Fixed DOS timestamp (1980-01-01 00:00:00) for reproducibility.169170const FIXED_DOS_TIME = 0x0000;171const FIXED_DOS_DATE = 0x0021;172173interface ZipEntry {174  path: string;175  data: Buffer;176}177178export function createDeterministicZip(entries: ZipEntry[]): Buffer {179  const sorted = [...entries].sort((a, b) =>180    a.path.localeCompare(b.path),181  );182  const localParts: Buffer[] = [];183  const centralParts: Buffer[] = [];184  let offset = 0;185186  for (const entry of sorted) {187    const filename = Buffer.from(entry.path, "utf8");188    const compressed = deflateRawSync(entry.data, { level: 9 });189    const crc = crc32(entry.data) >>> 0;190    const compSize = compressed.length;191    const uncompSize = entry.data.length;192193    // Local file header194    const lh = Buffer.alloc(30 + filename.length);195    lh.writeUInt32LE(0x04034b50, 0);196    lh.writeUInt16LE(20, 4);197    lh.writeUInt16LE(0, 6);198    lh.writeUInt16LE(8, 8);199    lh.writeUInt16LE(FIXED_DOS_TIME, 10);200    lh.writeUInt16LE(FIXED_DOS_DATE, 12);201    lh.writeUInt32LE(crc, 14);202    lh.writeUInt32LE(compSize, 18);203    lh.writeUInt32LE(uncompSize, 22);204    lh.writeUInt16LE(filename.length, 26);205    lh.writeUInt16LE(0, 28);206    filename.copy(lh, 30);207208    localParts.push(lh, compressed);209210    // Central directory record211    const cd = Buffer.alloc(46 + filename.length);212    cd.writeUInt32LE(0x02014b50, 0);213    cd.writeUInt16LE(20, 4);214    cd.writeUInt16LE(20, 6);215    cd.writeUInt16LE(0, 8);216    cd.writeUInt16LE(8, 10);217    cd.writeUInt16LE(FIXED_DOS_TIME, 12);218    cd.writeUInt16LE(FIXED_DOS_DATE, 14);219    cd.writeUInt32LE(crc, 16);220    cd.writeUInt32LE(compSize, 20);221    cd.writeUInt32LE(uncompSize, 24);222    cd.writeUInt16LE(filename.length, 28);223    cd.writeUInt16LE(0, 30);224    cd.writeUInt16LE(0, 32);225    cd.writeUInt16LE(0, 34);226    cd.writeUInt16LE(0, 36);227    cd.writeUInt32LE(0, 38);228    cd.writeUInt32LE(offset, 42);229    filename.copy(cd, 46);230231    centralParts.push(cd);232    offset += lh.length + compressed.length;233  }234235  const centralDir = Buffer.concat(centralParts);236  const centralOffset = offset;237  const centralSize = centralDir.length;238239  // End of central directory record240  const eocd = Buffer.alloc(22);241  eocd.writeUInt32LE(0x06054b50, 0);242  eocd.writeUInt16LE(0, 4);243  eocd.writeUInt16LE(0, 6);244  eocd.writeUInt16LE(sorted.length, 8);245  eocd.writeUInt16LE(sorted.length, 10);246  eocd.writeUInt32LE(centralSize, 12);247  eocd.writeUInt32LE(centralOffset, 16);248  eocd.writeUInt16LE(0, 20);249250  return Buffer.concat([...localParts, centralDir, eocd]);251}252253// ─── Package source archive ─────────────────────────────────────────────────254255function readVersion(manifestPath: string): string {256  const manifest = JSON.parse(257    readFileSync(join(REPO_ROOT, manifestPath), "utf8"),258  );259  if (!manifest.version) {260    throw new Error(`Could not read version from ${manifestPath}`);261  }262  return manifest.version;263}264265function ensureArtifactsDir(): string {266  const dir = join(REPO_ROOT, "artifacts");267  if (!existsSync(dir)) mkdirSync(dir);268  return dir;269}270271export function packageSourceArchive(version: string): string {272  const artifactsDir = ensureArtifactsDir();273  const archivePath = join(artifactsDir, `peach-source-${version}.zip`);274275  const files = collectSourceFiles();276  const manifest = buildSourceManifest(files, version);277  const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;278279  const entries: ZipEntry[] = files.map((path) => ({280    path,281    data: readFileSync(join(REPO_ROOT, path)),282  }));283  entries.push({284    path: "SOURCE-MANIFEST.json",285    data: Buffer.from(manifestJson, "utf8"),286  });287288  const zipBuffer = createDeterministicZip(entries);289  writeFileSync(archivePath, zipBuffer);290291  console.log(292    `Packaged source archive → ${basename(archivePath)} (${files.length} files + SOURCE-MANIFEST.json)`,293  );294  return archivePath;295}296297// ─── Standalone execution ───────────────────────────────────────────────────298299if (import.meta.main) {300  const version = readVersion("manifest.json");301  const archivePath = packageSourceArchive(version);302  console.log(`\nSource archive: ${archivePath}`);303}