diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md
index 6cd16ca68..3e932f7cc 100644
--- a/apps/cli-docs/src/content/docs/contributing.md
+++ b/apps/cli-docs/src/content/docs/contributing.md
@@ -88,7 +88,8 @@ cli/
│ │ ├── help.ts # Help command
│ │ ├── info.ts # Print configuration and verify authentication
│ │ ├── init.ts # Initialize Sentry in your project (experimental)
-│ │ └── schema.ts # Browse the Sentry API schema
+│ │ ├── schema.ts # Browse the Sentry API schema
+│ │ └── wasm-split.ts# Add build ids to WebAssembly modules and split out debug data
│ ├── lib/ # Shared utilities
│ └── types/ # TypeScript types and Valibot schemas
├── test/ # Test files (mirrors src/ structure)
diff --git a/apps/cli-docs/src/fragments/commands/wasm-split.md b/apps/cli-docs/src/fragments/commands/wasm-split.md
new file mode 100644
index 000000000..3b768a444
--- /dev/null
+++ b/apps/cli-docs/src/fragments/commands/wasm-split.md
@@ -0,0 +1,41 @@
+
+## Examples
+
+```bash
+# Add a build id to a module, in place, and print it
+sentry wasm-split app.wasm
+
+# Capture the build id for a later upload
+BUILD_ID=$(sentry wasm-split app.wasm)
+
+# Split debug data into a companion and ship a stripped binary
+sentry wasm-split app.wasm --debug-out app.debug.wasm --strip
+
+# Also drop function names from the shipped binary
+sentry wasm-split app.wasm -d app.debug.wasm --strip --strip-names
+
+# Point browsers at a companion served from a CDN
+sentry wasm-split app.wasm -o dist/app.wasm -d dist/app.debug.wasm --strip \
+ --external-dwarf-url https://cdn.example.com/debug/app.debug.wasm
+```
+
+## Important Notes
+
+- This is a **drop-in replacement for Symbolicator's `wasm-split` binary** —
+ same flags, same behaviour, same output.
+- **Only the build id is printed**, as lowercase hex, so it can be captured in
+ a shell variable. Pass `--quiet` to print nothing. `--quiet` cannot be
+ combined with `--json`.
+- A build id the module **already carries is reused**. Rewriting it would
+ orphan debug files uploaded against the old one. `--build-id` applies only
+ when the module has none.
+- The debug companion is a **complete copy** of the module, captured before
+ stripping. DWARF offsets are relative to the code section, so a companion
+ missing it cannot be symbolicated. Expect it to be about the size of the
+ input.
+- `--strip-names` takes effect **only alongside `--strip`**.
+- `external_debug_info` is written when `--external-dwarf-url` is given, or
+ else from the basename of `--debug-out`. A bare filename resolves relative to
+ the main wasm file, which is how Emscripten reads it.
+- The file is **left untouched when nothing changed** — no new build id, no
+ stripping, no `external_debug_info` — so reruns do not disturb build caches.
diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
index cb7038a39..9fb20ac1f 100644
--- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
+++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
@@ -703,6 +703,14 @@ Browse the Sentry API schema
→ Full flags and examples: `references/schema.md`
+### Wasm-split
+
+Add build ids to WebAssembly modules and split out debug data
+
+- `sentry wasm-split ` — Add build ids to WebAssembly modules and split out debug data
+
+→ Full flags and examples: `references/wasm-split.md`
+
## Global Options
All commands support the following global options:
diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md
new file mode 100644
index 000000000..66d1e0841
--- /dev/null
+++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md
@@ -0,0 +1,47 @@
+---
+name: sentry-cli-wasm-split
+version: 0.46.0-dev.0
+description: Add build ids to WebAssembly modules and split out debug data
+requires:
+ bins: ["sentry"]
+ auth: true
+---
+
+# Wasm-split Commands
+
+Add build ids to WebAssembly modules and split out debug data
+
+### `sentry wasm-split `
+
+Add build ids to WebAssembly modules and split out debug data
+
+**Flags:**
+- `-o, --out - Path to the output wasm file (default: modify input in place)`
+- `-d, --debug-out - Path to the output debug wasm file (default: debug data stays in the input)`
+- `--strip - Strip the file of debug info`
+- `--strip-names - Strip the file of symbol names (only with --strip)`
+- `-q, --quiet - Do not print the build id`
+- `--build-id - Explicit build id to use, as a UUID`
+- `--external-dwarf-url - URL for browsers to fetch the separate DWARF debug symbol file`
+
+**Examples:**
+
+```bash
+# Add a build id to a module, in place, and print it
+sentry wasm-split app.wasm
+
+# Capture the build id for a later upload
+BUILD_ID=$(sentry wasm-split app.wasm)
+
+# Split debug data into a companion and ship a stripped binary
+sentry wasm-split app.wasm --debug-out app.debug.wasm --strip
+
+# Also drop function names from the shipped binary
+sentry wasm-split app.wasm -d app.debug.wasm --strip --strip-names
+
+# Point browsers at a companion served from a CDN
+sentry wasm-split app.wasm -o dist/app.wasm -d dist/app.debug.wasm --strip \
+ --external-dwarf-url https://cdn.example.com/debug/app.debug.wasm
+```
+
+All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags.
diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts
index af498349d..487290140 100644
--- a/packages/cli/src/app.ts
+++ b/packages/cli/src/app.ts
@@ -62,6 +62,7 @@ import { traceRoute } from "./commands/trace/index.js";
import { listCommand as traceListCommand } from "./commands/trace/list.js";
import { trialRoute } from "./commands/trial/index.js";
import { listCommand as trialListCommand } from "./commands/trial/list.js";
+import { wasmSplitCommand } from "./commands/wasm-split.js";
import {
getCommandSuggestion,
getSynonymSuggestionFromArgv,
@@ -149,6 +150,7 @@ export const routes = buildRouteMap({
local: localRoute,
api: apiCommand,
schema: schemaCommand,
+ "wasm-split": wasmSplitCommand,
// Backward-compat aliases for old sentry-cli — hidden from help
"send-event": sendEventCommand,
"send-envelope": sendEnvelopeCommand,
diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts
new file mode 100644
index 000000000..9b3982d7f
--- /dev/null
+++ b/packages/cli/src/commands/wasm-split.ts
@@ -0,0 +1,240 @@
+/**
+ * sentry wasm-split
+ *
+ * Add a build id to a WebAssembly module, and optionally split its debug data
+ * into a companion file.
+ *
+ * A drop-in replacement for Symbolicator's Rust `wasm-split` binary: same
+ * flags, same behaviour, and the same stdout contract of a single lowercase hex
+ * build id, so `BUILD_ID=$(sentry wasm-split app.wasm)` keeps working for
+ * anyone moving off the Rust tool.
+ *
+ * "Drop-in" is meant to cover which files are rejected too, not just what the
+ * accepted ones turn into. The same modules go through and the same ones fail,
+ * because the parser is calibrated against the Rust tool's own; see
+ * `lib/wasm/binary.ts`.
+ */
+
+import { readFile, writeFile } from "node:fs/promises";
+import { basename } from "node:path";
+import type { SentryContext } from "../context.js";
+import { buildCommand } from "../lib/command.js";
+import { ValidationError } from "../lib/errors.js";
+import { CommandOutput } from "../lib/formatters/output.js";
+import { formatBuildId, uuidToBytes } from "../lib/wasm/build-id.js";
+import { splitWasm } from "../lib/wasm/split.js";
+
+/** Flags accepted by `sentry wasm-split`. */
+type WasmSplitFlags = {
+ out?: string;
+ "debug-out"?: string;
+ strip: boolean;
+ "strip-names": boolean;
+ quiet: boolean;
+ json?: boolean;
+ "build-id"?: string;
+ "external-dwarf-url"?: string;
+};
+
+/** Structured result for the wasm-split command. */
+type WasmSplitResult = {
+ /** The module's build id, as lowercase hex. */
+ buildId: string;
+ /** Path of the module that was read. */
+ input: string;
+ /** Where the deployable module was written, absent when nothing changed. */
+ output?: string;
+ /** Where the debug companion was written, absent unless one was requested. */
+ debugOutput?: string;
+};
+
+export const wasmSplitCommand = buildCommand({
+ docs: {
+ brief: "Add build ids to WebAssembly modules and split out debug data",
+ fullDescription:
+ "Add a build id to a WebAssembly module and optionally split its debug " +
+ "data into a companion file. Sentry matches a wasm stack frame to its " +
+ "debug file by build id, so a module without one can never be " +
+ "symbolicated.\n\n" +
+ "An id the module already carries is reused; otherwise one is minted " +
+ "and written into the module. The build id is printed to stdout as " +
+ "lowercase hex and nothing else, so it can be captured in a shell " +
+ "variable.\n\n" +
+ "The debug companion is a complete copy of the module, captured before " +
+ "stripping. DWARF offsets are relative to the code section, so a " +
+ "companion missing it cannot be symbolicated.\n\n" +
+ "This is a drop-in replacement for Symbolicator's `wasm-split` binary.\n\n" +
+ "Usage:\n" +
+ " sentry wasm-split app.wasm\n" +
+ " sentry wasm-split app.wasm -d app.debug.wasm --strip\n" +
+ " BUILD_ID=$(sentry wasm-split app.wasm)",
+ },
+ // Purely local file operation — no Sentry API calls, no auth needed.
+ auth: false,
+ output: {
+ // Print only the bare build id, matching the Rust binary's stdout.
+ human: (data: WasmSplitResult) => data.buildId,
+ jsonExclude: [],
+ },
+ parameters: {
+ positional: {
+ kind: "tuple",
+ parameters: [
+ {
+ brief: "Path to the wasm file",
+ parse: String,
+ placeholder: "input",
+ },
+ ],
+ },
+ flags: {
+ out: {
+ kind: "parsed",
+ parse: String,
+ brief: "Path to the output wasm file (default: modify input in place)",
+ optional: true,
+ },
+ "debug-out": {
+ kind: "parsed",
+ parse: String,
+ brief:
+ "Path to the output debug wasm file (default: debug data stays in the input)",
+ optional: true,
+ },
+ strip: {
+ kind: "boolean",
+ brief: "Strip the file of debug info",
+ default: false,
+ optional: true,
+ },
+ "strip-names": {
+ kind: "boolean",
+ brief: "Strip the file of symbol names (only with --strip)",
+ default: false,
+ optional: true,
+ },
+ quiet: {
+ kind: "boolean",
+ brief: "Do not print the build id",
+ default: false,
+ optional: true,
+ },
+ "build-id": {
+ kind: "parsed",
+ parse: String,
+ brief: "Explicit build id to use, as a UUID",
+ optional: true,
+ },
+ "external-dwarf-url": {
+ kind: "parsed",
+ parse: String,
+ brief: "URL for browsers to fetch the separate DWARF debug symbol file",
+ optional: true,
+ },
+ },
+ aliases: {
+ o: "out",
+ d: "debug-out",
+ q: "quiet",
+ },
+ },
+ async *func(this: SentryContext, flags: WasmSplitFlags, input: string) {
+ if (!input?.trim()) {
+ throw new ValidationError(
+ "Wasm file path is required: sentry wasm-split ",
+ "input"
+ );
+ }
+
+ if (flags.quiet && flags.json) {
+ throw new ValidationError("--quiet cannot be used with --json.", "quiet");
+ }
+
+ const explicitBuildId = parseExplicitBuildId(flags["build-id"]);
+ const debugOut = flags["debug-out"];
+
+ const result = splitWasm(await readWasmModule(input), {
+ ...(explicitBuildId ? { buildId: explicitBuildId } : {}),
+ companion: debugOut !== undefined,
+ strip: flags.strip,
+ stripNames: flags["strip-names"],
+ ...resolveExternalDebugInfo(flags["external-dwarf-url"], debugOut),
+ });
+
+ // Before the main module, so a failure here never leaves a stripped binary
+ // with no companion to symbolicate it.
+ if (debugOut !== undefined && result.companion) {
+ await writeFile(debugOut, result.companion);
+ }
+
+ const output = flags.out ?? input;
+ if (result.moduleChanged) {
+ await writeFile(output, result.module);
+ }
+
+ if (flags.quiet) {
+ return {};
+ }
+
+ yield new CommandOutput({
+ buildId: formatBuildId(result.buildId),
+ input,
+ ...(result.moduleChanged ? { output } : {}),
+ ...(debugOut !== undefined ? { debugOutput: debugOut } : {}),
+ });
+ return {};
+ },
+});
+
+/** Validate `--build-id`, which the Rust binary parses as a UUID. */
+function parseExplicitBuildId(value: string | undefined): Uint8Array | null {
+ if (value === undefined) {
+ return null;
+ }
+ const bytes = uuidToBytes(value);
+ if (!bytes) {
+ throw new ValidationError(
+ `Invalid --build-id '${value}': expected a UUID.`,
+ "build-id"
+ );
+ }
+ return bytes;
+}
+
+/**
+ * Resolve the value of the `external_debug_info` section.
+ *
+ * `--external-dwarf-url` wins; otherwise the basename of `--debug-out` is a
+ * reasonable default, because a bare filename resolves relative to the main
+ * wasm file. Emscripten falls back the same way.
+ */
+function resolveExternalDebugInfo(
+ url: string | undefined,
+ debugOut: string | undefined
+): { externalDebugInfo?: string } {
+ const resolved = url ?? (debugOut ? basename(debugOut) : undefined);
+ return resolved ? { externalDebugInfo: resolved } : {};
+}
+
+/** Read a wasm module, turning the usual path mistakes into clear errors. */
+async function readWasmModule(path: string): Promise {
+ try {
+ return await readFile(path);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ throw new ValidationError(`Wasm file '${path}' does not exist.`, "input");
+ }
+ if (code === "EISDIR") {
+ throw new ValidationError(
+ `Path '${path}' is a directory, not a wasm file.`,
+ "input"
+ );
+ }
+ const msg = err instanceof Error ? err.message : String(err);
+ throw new ValidationError(
+ `Cannot read wasm file '${path}': ${msg}`,
+ "input"
+ );
+ }
+}
diff --git a/packages/cli/src/lib/wasm/binary.ts b/packages/cli/src/lib/wasm/binary.ts
new file mode 100644
index 000000000..4af1f3ecd
--- /dev/null
+++ b/packages/cli/src/lib/wasm/binary.ts
@@ -0,0 +1,493 @@
+/**
+ * Reading and writing the WebAssembly binary envelope.
+ *
+ * Only the module envelope is modelled: the 8-byte header followed by a flat,
+ * ordered list of sections. Section payloads stay opaque byte ranges, because
+ * every tool in this area needs to add or drop a custom section while leaving
+ * code, data, and anything this parser has never heard of exactly as it found
+ * them.
+ *
+ * Payloads being opaque does not make the envelope unchecked. Validation goes
+ * exactly as deep as `wasmbin`, the parser behind the Rust `wasm-split`, and no
+ * deeper: `wasmbin` reads each section as a lazy length-prefixed blob, so it
+ * accepts a module whose code section is nonsense, and rejects one whose
+ * envelope is wrong — a section id the spec does not define, or sections out of
+ * the mandated order. `WebAssembly.validate` is not a substitute. It also
+ * type-checks function bodies, and so rejects files the Rust tool accepts.
+ *
+ * Round-trip fidelity is a hard requirement, not a convenience. A module whose
+ * sections are parsed and re-encoded unchanged must come out byte-identical, so
+ * each parsed section keeps a view of its original bytes and is emitted
+ * verbatim. That covers section kinds this parser does not recognise, and also
+ * non-canonical LEB128 lengths, which some toolchains pad. `wasm-split` once
+ * shipped a bug in this exact area (symbolicator#311, "wasm-split now retains
+ * all sections"), and stripping a section a user did not ask to lose is silent
+ * until symbolication fails.
+ *
+ * Custom section bodies follow `wasmbin`'s encoding, so files written here are
+ * interchangeable with those written by the Rust `wasm-split`.
+ */
+
+import { logger } from "../logger.js";
+
+const log = logger.withTag("wasm.binary");
+
+/** Section id of a custom section. */
+const CUSTOM_SECTION_ID = 0;
+
+/** Name of the custom section holding function names. */
+const NAME_SECTION = "name";
+
+/** Name of the custom section holding a module's build id. */
+export const BUILD_ID_SECTION = "build_id";
+
+/** Name of the custom section pointing at a module's debug companion. */
+const EXTERNAL_DEBUG_INFO_SECTION = "external_debug_info";
+
+/** Prefix shared by the custom sections that carry DWARF. */
+const DEBUG_SECTION_PREFIX = ".debug_";
+
+/**
+ * Non-custom section ids, in the order the spec mandates they appear.
+ *
+ * Deliberately not sorted by id. The data count section (12) was numbered after
+ * the code section (10) but has to precede it, and the exception tag section
+ * (13) belongs between memory and global. `wasmbin` spells the same sequence as
+ * the declaration order of its section enum, and the Rust `wasm-split` builds it
+ * with the `exception-handling` feature on, which is what makes 13 legal here.
+ */
+const SECTION_ORDER = [
+ [1, "type"],
+ [2, "import"],
+ [3, "function"],
+ [4, "table"],
+ [5, "memory"],
+ [13, "exception tag"],
+ [6, "global"],
+ [7, "export"],
+ [8, "start"],
+ [9, "element"],
+ [12, "data count"],
+ [10, "code"],
+ [11, "data"],
+] as const;
+
+/** What a non-custom section id means, and where it sorts. */
+type SectionKind = {
+ /** Human-readable name, for error messages. */
+ name: string;
+ /** Position in {@link SECTION_ORDER}. */
+ rank: number;
+};
+
+/** {@link SECTION_ORDER} keyed by section id. */
+const SECTION_KINDS = new Map(
+ SECTION_ORDER.map(([id, name], rank) => [id, { name, rank }])
+);
+
+/** Magic bytes and version that open every WebAssembly module. */
+const WASM_HEADER = Uint8Array.from([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+]);
+
+/** Bytes consumed by {@link WASM_HEADER}. */
+const WASM_HEADER_LENGTH = WASM_HEADER.length;
+
+/** Continuation flag of a LEB128 group: another byte follows. */
+const CONTINUATION_BIT = 0x80;
+
+/** Value bits of a LEB128 group. */
+const PAYLOAD_MASK = 0x7f;
+
+/** Distinct values a LEB128 group can hold. */
+const GROUP_SIZE = 128;
+
+/** Groups needed to hold a 32-bit value, and so the most we will read. */
+const MAX_VARUINT32_BYTES = 5;
+
+/** Largest value a varuint32 may decode to. */
+const MAX_UINT32 = 0xff_ff_ff_ff;
+
+/** Raised when a byte stream is not a WebAssembly module this parser can read. */
+export class WasmParseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "WasmParseError";
+ }
+}
+
+/**
+ * One section of a module, as found on disk or as constructed for writing.
+ *
+ * `name` and `contents` are set only for custom sections whose header could be
+ * read; a custom section with a malformed name is still carried, just without
+ * them, so one bad section never costs the caller the rest of the file.
+ */
+export type WasmSection = {
+ /** Section id. {@link CUSTOM_SECTION_ID} for custom sections. */
+ id: number;
+ /** Name of a custom section, absent when unnamed or unreadable. */
+ name?: string;
+ /** Bytes of a custom section after its name. */
+ contents?: Uint8Array;
+ /** The section's full payload, excluding its id and length prefix. */
+ payload: Uint8Array;
+ /**
+ * The section exactly as it appeared on disk, including id and length
+ * prefix. Present only for parsed sections, and emitted verbatim by
+ * {@link encodeModule} so an untouched section round-trips byte-for-byte.
+ */
+ raw?: Uint8Array;
+};
+
+/** A varuint32 read off a byte stream. */
+export type VarUint32 = {
+ /** The decoded value. */
+ value: number;
+ /** Bytes the encoding occupied. */
+ size: number;
+};
+
+// biome-ignore-start lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
+/**
+ * Read an unsigned LEB128 32-bit integer.
+ *
+ * @param bytes - Buffer to read from
+ * @param offset - Index of the first byte of the encoding
+ * @returns The value and the number of bytes it occupied
+ * @throws {WasmParseError} when the encoding runs past the buffer, spans more
+ * groups than a 32-bit value can need, or decodes above 2^32 - 1
+ */
+export function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 {
+ let value = 0;
+ let scale = 1;
+ for (let size = 0; size < MAX_VARUINT32_BYTES; size++) {
+ const byte = bytes[offset + size];
+ if (byte === undefined) {
+ throw new WasmParseError(
+ `truncated LEB128 integer at offset ${offset + size}`
+ );
+ }
+ // Arithmetic rather than `<<`: a 5th group shifted by 28 would overflow
+ // into the sign bit of a 32-bit bitwise operand.
+ value += (byte & PAYLOAD_MASK) * scale;
+ if ((byte & CONTINUATION_BIT) === 0) {
+ if (value > MAX_UINT32) {
+ throw new WasmParseError(
+ `LEB128 integer at offset ${offset} exceeds 32 bits`
+ );
+ }
+ return { value, size: size + 1 };
+ }
+ scale *= GROUP_SIZE;
+ }
+ throw new WasmParseError(
+ `LEB128 integer at offset ${offset} is longer than ${MAX_VARUINT32_BYTES} bytes`
+ );
+}
+
+/**
+ * Encode an unsigned LEB128 32-bit integer, canonically.
+ *
+ * @param value - A non-negative integer below 2^32
+ * @returns The encoded bytes, one to five of them
+ */
+export function writeVarUint32(value: number): Uint8Array {
+ const bytes: number[] = [];
+ let remaining = value;
+ do {
+ const group = remaining % GROUP_SIZE;
+ remaining = Math.floor(remaining / GROUP_SIZE);
+ bytes.push(remaining === 0 ? group : group | CONTINUATION_BIT);
+ } while (remaining !== 0);
+ return Uint8Array.from(bytes);
+}
+// biome-ignore-end lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
+
+/**
+ * Split a module into its sections, in file order.
+ *
+ * Every section keeps a view of its original bytes, so passing the result
+ * straight to {@link encodeModule} reproduces the input exactly.
+ *
+ * @param bytes - A complete WebAssembly module
+ * @returns The module's sections, in the order they appear
+ * @throws {WasmParseError} when the header is wrong, a section runs past the end
+ * of the buffer, a section id is not one the spec defines, or the sections are
+ * out of the mandated order
+ */
+export function parseSections(bytes: Uint8Array): WasmSection[] {
+ assertWasmHeader(bytes);
+ const sections: WasmSection[] = [];
+ let offset = WASM_HEADER_LENGTH;
+ let lastRank = -1;
+ while (offset < bytes.length) {
+ const start = offset;
+ const id = bytes[offset] as number;
+ offset += 1;
+ // Id first, then length, then position: the order `wasmbin` reports these
+ // in, so a module wrong in two ways gets the same complaint from both tools.
+ const kind = sectionKind(id, start);
+ const { value: payloadLength, size } = readVarUint32(bytes, offset);
+ offset += size;
+ const payloadEnd = offset + payloadLength;
+ if (payloadEnd > bytes.length) {
+ throw new WasmParseError(
+ `section at offset ${start} claims ${payloadLength} bytes but only ${bytes.length - offset} remain`
+ );
+ }
+ lastRank = checkSectionOrder(kind, lastRank, start);
+ const payload = bytes.subarray(offset, payloadEnd);
+ sections.push({
+ id,
+ ...readCustomHeader(id, payload),
+ payload,
+ raw: bytes.subarray(start, payloadEnd),
+ });
+ offset = payloadEnd;
+ }
+ return sections;
+}
+
+/**
+ * Reassemble sections into a module.
+ *
+ * Sections carrying their original bytes are written verbatim; sections built
+ * by the constructors below are encoded canonically.
+ *
+ * @param sections - Sections to write, in the order they should appear
+ * @returns The complete module
+ */
+export function encodeModule(sections: WasmSection[]): Uint8Array {
+ const parts: Uint8Array[] = [WASM_HEADER];
+ for (const section of sections) {
+ parts.push(section.raw ?? encodeSection(section));
+ }
+ return concatBytes(parts);
+}
+
+/**
+ * Build a custom section from a name and an already-encoded body.
+ *
+ * @param name - Section name, as it appears in the module
+ * @param contents - Everything after the name
+ * @returns A section ready to hand to {@link encodeModule}
+ */
+function makeCustomSection(name: string, contents: Uint8Array): WasmSection {
+ const nameBytes = new TextEncoder().encode(name);
+ const payload = concatBytes([
+ writeVarUint32(nameBytes.length),
+ nameBytes,
+ contents,
+ ]);
+ return { id: CUSTOM_SECTION_ID, name, contents, payload };
+}
+
+/**
+ * Build a `build_id` custom section.
+ *
+ * The body is a length-prefixed byte vector, matching `wasmbin`'s
+ * `CustomSection::BuildId(Vec)`.
+ *
+ * @param buildId - Raw build id bytes, conventionally a 16-byte UUID
+ */
+export function makeBuildIdSection(buildId: Uint8Array): WasmSection {
+ return makeCustomSection(BUILD_ID_SECTION, encodeByteVector(buildId));
+}
+
+/**
+ * Build an `external_debug_info` custom section.
+ *
+ * The body is a length-prefixed UTF-8 string, matching `wasmbin`'s
+ * `CustomSection::ExternalDebugInfo(Lazy)`.
+ *
+ * @param url - Where the debug companion can be fetched. A bare filename
+ * resolves relative to the module, which is how Emscripten reads it.
+ */
+export function makeExternalDebugInfoSection(url: string): WasmSection {
+ return makeCustomSection(
+ EXTERNAL_DEBUG_INFO_SECTION,
+ encodeByteVector(new TextEncoder().encode(url))
+ );
+}
+
+/**
+ * Read the bytes out of a `build_id` section body.
+ *
+ * @param contents - The section body, after its name
+ * @returns The build id, or `null` when the body is malformed
+ */
+export function decodeBuildId(contents: Uint8Array): Uint8Array | null {
+ return decodeByteVector(contents, BUILD_ID_SECTION);
+}
+
+/** Whether a section is one of the custom sections carrying DWARF. */
+export function isDebugSection(section: WasmSection): boolean {
+ return (
+ section.id === CUSTOM_SECTION_ID &&
+ section.name !== undefined &&
+ section.name.startsWith(DEBUG_SECTION_PREFIX)
+ );
+}
+
+/** Whether a section is the custom section holding function names. */
+export function isNameSection(section: WasmSection): boolean {
+ return section.id === CUSTOM_SECTION_ID && section.name === NAME_SECTION;
+}
+
+/** Throw unless `bytes` opens with the WebAssembly magic and version. */
+function assertWasmHeader(bytes: Uint8Array): void {
+ if (bytes.length < WASM_HEADER_LENGTH) {
+ throw new WasmParseError(
+ `too short to be a WebAssembly module (${bytes.length} bytes)`
+ );
+ }
+ for (let index = 0; index < WASM_HEADER_LENGTH; index++) {
+ if (bytes[index] !== WASM_HEADER[index]) {
+ throw new WasmParseError(
+ "not a WebAssembly module: bad magic or unsupported version"
+ );
+ }
+ }
+}
+
+/**
+ * Resolve a section id to its kind, rejecting ids the spec does not define.
+ *
+ * A custom section has no kind: it may appear anywhere, as often as it likes, so
+ * it takes no part in the section order.
+ *
+ * @returns The kind, or `null` for a custom section
+ * @throws {WasmParseError} when no released spec defines the id
+ */
+function sectionKind(id: number, offset: number): SectionKind | null {
+ if (id === CUSTOM_SECTION_ID) {
+ return null;
+ }
+ const kind = SECTION_KINDS.get(id);
+ if (kind === undefined) {
+ throw new WasmParseError(
+ `unknown section id ${id} at offset ${offset}: not a section any released WebAssembly version defines`
+ );
+ }
+ return kind;
+}
+
+/**
+ * Check where a section sits relative to the one before it.
+ *
+ * Ranks have to strictly increase, which rejects a section placed before one it
+ * should follow and, because equal ranks are refused too, a second copy of a
+ * section that may appear only once.
+ *
+ * @returns The rank to compare the next section against
+ * @throws {WasmParseError} when the section is out of order or repeated
+ */
+function checkSectionOrder(
+ kind: SectionKind | null,
+ lastRank: number,
+ offset: number
+): number {
+ if (kind === null) {
+ return lastRank;
+ }
+ if (kind.rank <= lastRank) {
+ throw new WasmParseError(
+ `${kind.name} section at offset ${offset} is out of order or repeated`
+ );
+ }
+ return kind.rank;
+}
+
+/**
+ * Read a custom section's name and body.
+ *
+ * Returns nothing for a non-custom section, and nothing for a custom section
+ * whose name is truncated or not valid UTF-8 — the section itself is still
+ * carried, so a single bad header costs only the ability to address it by name.
+ *
+ * Skipping rather than rejecting is what the Rust tool does. `wasmbin` decodes a
+ * custom section's name only when something asks for it, and `wasm-split` drops
+ * that error on the floor, so an unreadable name costs the section its identity
+ * and nothing more.
+ */
+function readCustomHeader(
+ id: number,
+ payload: Uint8Array
+): { name?: string; contents?: Uint8Array } {
+ if (id !== CUSTOM_SECTION_ID) {
+ return {};
+ }
+ try {
+ const { value: nameLength, size } = readVarUint32(payload, 0);
+ const nameEnd = size + nameLength;
+ if (nameEnd > payload.length) {
+ log.debug("custom section name runs past the section, ignoring name");
+ return {};
+ }
+ return {
+ name: new TextDecoder("utf-8", { fatal: true }).decode(
+ payload.subarray(size, nameEnd)
+ ),
+ contents: payload.subarray(nameEnd),
+ };
+ } catch (error) {
+ log.debug("unreadable custom section name, ignoring name", error);
+ return {};
+ }
+}
+
+/** Encode a section body constructed for writing, with its length prefix. */
+function encodeSection(section: WasmSection): Uint8Array {
+ return concatBytes([
+ Uint8Array.from([section.id]),
+ writeVarUint32(section.payload.length),
+ section.payload,
+ ]);
+}
+
+/** Prefix bytes with their length, as `wasmbin` encodes `Vec` and `String`. */
+function encodeByteVector(bytes: Uint8Array): Uint8Array {
+ return concatBytes([writeVarUint32(bytes.length), bytes]);
+}
+
+/**
+ * Read a length-prefixed byte vector that spans its whole buffer.
+ *
+ * The length must account for every remaining byte: a prefix that disagrees
+ * with the section it lives in means the body was not written by a tool that
+ * agrees with us about the format, and guessing would be worse than declining.
+ */
+function decodeByteVector(
+ contents: Uint8Array,
+ sectionName: string
+): Uint8Array | null {
+ try {
+ const { value: length, size } = readVarUint32(contents, 0);
+ if (size + length !== contents.length) {
+ log.debug(
+ `${sectionName} declares ${length} bytes but holds ${contents.length - size}`
+ );
+ return null;
+ }
+ return contents.subarray(size, size + length);
+ } catch (error) {
+ log.debug(`unreadable ${sectionName} body`, error);
+ return null;
+ }
+}
+
+/** Join byte ranges into one buffer. */
+function concatBytes(parts: Uint8Array[]): Uint8Array {
+ let total = 0;
+ for (const part of parts) {
+ total += part.length;
+ }
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const part of parts) {
+ out.set(part, offset);
+ offset += part.length;
+ }
+ return out;
+}
diff --git a/packages/cli/src/lib/wasm/build-id.ts b/packages/cli/src/lib/wasm/build-id.ts
new file mode 100644
index 000000000..3effc5f54
--- /dev/null
+++ b/packages/cli/src/lib/wasm/build-id.ts
@@ -0,0 +1,79 @@
+/**
+ * The `build_id` custom section of a WebAssembly module.
+ *
+ * Sentry matches a stack frame to its debug file by build id, so every module
+ * that might appear in a stack trace needs one — and a module and its debug
+ * companion must carry the same one. This module owns generating and reading
+ * that id; deciding *which* modules to stamp belongs to the callers.
+ *
+ * The encoding follows the WebAssembly tool conventions, so ids written here
+ * are interchangeable with those written by the Rust `wasm-split`.
+ */
+
+import { UUID, uuidv4obj } from "uuidv7";
+import { BUILD_ID_SECTION, decodeBuildId, type WasmSection } from "./binary.js";
+
+/**
+ * Render build id bytes as lowercase hex.
+ *
+ * Used by `wasm-split` for stdout and `--json` output; accepts any length so
+ * ids read from a module match the Rust tool's `hex::encode`, not only UUIDs.
+ */
+export function formatBuildId(buildId: Uint8Array): string {
+ return Buffer.from(buildId).toString("hex");
+}
+
+/**
+ * Parse a UUID string into its 16 raw bytes.
+ *
+ * Used by `wasm-split` for `--build-id`; returns `null` instead of throwing so
+ * the command can raise a `ValidationError` rather than a `SyntaxError`.
+ *
+ * @param uuid - A UUID, with or without hyphens, in either case
+ * @returns The bytes, or `null` when the string is not a UUID
+ */
+export function uuidToBytes(uuid: string): Uint8Array | null {
+ // biome-ignore lint/plugin: a parse failure is the answer — the caller turns null into its own error
+ try {
+ return new Uint8Array(UUID.parse(uuid).bytes);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Generate a random v4 build id.
+ *
+ * Used by `splitWasm` when the module carries no readable id and the caller
+ * did not pass `--build-id`.
+ */
+export function randomBuildId(): Uint8Array {
+ return new Uint8Array(uuidv4obj().bytes);
+}
+
+/**
+ * Read the build id out of already-parsed sections.
+ *
+ * Used by `splitWasm` before minting or stamping; an existing id must be reused
+ * so debug files already uploaded against it stay matched.
+ *
+ * The first readable `build_id` wins, and a malformed section is skipped rather
+ * than treated as an answer — both matching the Rust tool, where a section that
+ * fails to decode never reaches `find_map`. A module should carry at most one
+ * id, but the distinction still matters: minting a fresh id for a module that
+ * already has a good one would orphan every debug file uploaded against it.
+ */
+export function buildIdFromSections(
+ sections: WasmSection[]
+): Uint8Array | null {
+ for (const section of sections) {
+ if (section.name !== BUILD_ID_SECTION || !section.contents) {
+ continue;
+ }
+ const buildId = decodeBuildId(section.contents);
+ if (buildId) {
+ return buildId;
+ }
+ }
+ return null;
+}
diff --git a/packages/cli/src/lib/wasm/split.ts b/packages/cli/src/lib/wasm/split.ts
new file mode 100644
index 000000000..8afc6b151
--- /dev/null
+++ b/packages/cli/src/lib/wasm/split.ts
@@ -0,0 +1,146 @@
+/**
+ * Splitting a WebAssembly module into a deployable binary and a debug
+ * companion.
+ *
+ * This is a direct port of Symbolicator's Rust `wasm-split`, and the ordering
+ * of the steps is part of the contract rather than an implementation detail.
+ * See {@link splitWasm} for what that ordering buys.
+ *
+ * Parity covers which modules are refused as well as what happens to the ones
+ * that are accepted. Parsing is the gate, and it is calibrated against `wasmbin`
+ * rather than against a full validator; see `binary.ts` for how deep that goes.
+ *
+ * The function is pure: it takes bytes and returns bytes. Reading and writing
+ * files, resolving paths, and reporting to the user all belong to the caller,
+ * which is what lets the `wasm-split` command and the debug-file pipeline share
+ * it without sharing anything else.
+ */
+
+import {
+ encodeModule,
+ isDebugSection,
+ isNameSection,
+ makeBuildIdSection,
+ makeExternalDebugInfoSection,
+ parseSections,
+ type WasmSection,
+} from "./binary.js";
+import { buildIdFromSections, randomBuildId } from "./build-id.js";
+
+/** How to split a module. Every field is optional; the default is a no-op. */
+export type SplitWasmOptions = {
+ /**
+ * Build id to stamp when the module carries none. Defaults to a random v4
+ * UUID. Ignored when the module already has an id, which always wins.
+ */
+ buildId?: Uint8Array;
+ /**
+ * Produce a debug companion. The companion is a complete copy of the module,
+ * so this costs roughly the size of the input.
+ */
+ companion?: boolean;
+ /** Drop the `.debug_*` custom sections from the deployable module. */
+ strip?: boolean;
+ /**
+ * Also drop the `name` section. Takes effect only alongside {@link strip},
+ * mirroring the Rust control flow, where the strip predicate is reached only
+ * inside the `--strip` branch.
+ */
+ stripNames?: boolean;
+ /**
+ * Where a browser can fetch the debug companion, written to the module as an
+ * `external_debug_info` section. Already resolved: a bare filename is stored
+ * as given, and Emscripten reads it relative to the module.
+ */
+ externalDebugInfo?: string;
+};
+
+/** The outcome of a split. */
+export type SplitWasmResult = {
+ /** The module's effective build id, existing or freshly minted. */
+ buildId: Uint8Array;
+ /** The deployable module, re-encoded. */
+ module: Uint8Array;
+ /**
+ * Whether the deployable module differs from the input. When `false`, the
+ * caller should skip the write: the bytes are identical, and not touching the
+ * file keeps timestamps and build caches intact.
+ */
+ moduleChanged: boolean;
+ /** The debug companion, present only when {@link SplitWasmOptions.companion}. */
+ companion?: Uint8Array;
+};
+
+/**
+ * Split a module.
+ *
+ * The steps run in a fixed order, and two of them matter:
+ *
+ * The build id is settled first, so both outputs carry the same one. That is
+ * the whole point of the id — it is what pairs a stack frame with its debug
+ * file.
+ *
+ * The companion is captured second, before any stripping. It is therefore a
+ * complete copy, code section included. DWARF offsets are relative to the code
+ * section, so a companion without it cannot be symbolicated, however much
+ * `.debug_*` data it holds.
+ *
+ * Nothing is built until the input has been parsed in full, so a module the Rust
+ * tool would refuse costs the caller an error and no output.
+ *
+ * @param bytes - A complete WebAssembly module
+ * @param options - How to split it
+ * @returns The effective build id, the deployable module, and the companion
+ * @throws {import("./binary.js").WasmParseError} when `bytes` is not a module the
+ * Rust `wasm-split` would accept
+ */
+export function splitWasm(
+ bytes: Uint8Array,
+ options: SplitWasmOptions = {}
+): SplitWasmResult {
+ let sections = parseSections(bytes);
+ let moduleChanged = false;
+
+ // An id the module already carries wins: rewriting it would orphan debug
+ // files uploaded against the old one.
+ let buildId = buildIdFromSections(sections);
+ if (buildId === null) {
+ buildId = options.buildId ?? randomBuildId();
+ sections = [...sections, makeBuildIdSection(buildId)];
+ moduleChanged = true;
+ }
+
+ // Before stripping, so the companion keeps every section.
+ const companion = options.companion ? encodeModule(sections) : undefined;
+
+ if (options.strip) {
+ const stripNames = options.stripNames ?? false;
+ const kept = sections.filter(
+ (section) => !isStrippable(section, stripNames)
+ );
+ if (kept.length !== sections.length) {
+ sections = kept;
+ moduleChanged = true;
+ }
+ }
+
+ if (options.externalDebugInfo) {
+ sections = [
+ ...sections,
+ makeExternalDebugInfoSection(options.externalDebugInfo),
+ ];
+ moduleChanged = true;
+ }
+
+ return {
+ buildId,
+ module: encodeModule(sections),
+ moduleChanged,
+ ...(companion ? { companion } : {}),
+ };
+}
+
+/** Whether `--strip` should drop this section. */
+function isStrippable(section: WasmSection, stripNames: boolean): boolean {
+ return isNameSection(section) ? stripNames : isDebugSection(section);
+}
diff --git a/packages/cli/test/commands/wasm-split.test.ts b/packages/cli/test/commands/wasm-split.test.ts
new file mode 100644
index 000000000..9260ca300
--- /dev/null
+++ b/packages/cli/test/commands/wasm-split.test.ts
@@ -0,0 +1,247 @@
+/**
+ * Tests for `sentry wasm-split`.
+ *
+ * Drives the command through its wrapper `loader()` with a fake stdout, so the
+ * stdout contract — a bare lowercase hex build id and nothing else — is
+ * asserted on the real output path rather than on the return value.
+ */
+
+import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, test } from "vitest";
+import { wasmSplitCommand } from "../../src/commands/wasm-split.js";
+import { ValidationError } from "../../src/lib/errors.js";
+import { parseSections } from "../../src/lib/wasm/binary.js";
+import {
+ buildIdFromSections,
+ formatBuildId,
+} from "../../src/lib/wasm/build-id.js";
+import {
+ byteVector,
+ CODE_SECTION_ID,
+ customSection,
+ fromHex,
+ readByteVectorString,
+ section,
+ toHex,
+ wasmModule,
+} from "../lib/wasm/helpers.js";
+
+/** Flags the command sees when the user passes none. */
+const NO_FLAGS = {
+ strip: false,
+ "strip-names": false,
+ quiet: false,
+} as const;
+
+function createContext() {
+ const writes: string[] = [];
+ return {
+ context: {
+ stdout: {
+ write: (data: string | Uint8Array) => {
+ writes.push(
+ typeof data === "string" ? data : new TextDecoder().decode(data)
+ );
+ return true;
+ },
+ },
+ stderr: { write: () => true },
+ cwd: "/tmp",
+ env: {} as NodeJS.ProcessEnv,
+ process: { ...process, exitCode: undefined } as typeof process,
+ },
+ output: () => writes.join(""),
+ };
+}
+
+/** Run the command, returning its stdout. */
+async function run(
+ flags: Record,
+ input: string
+): Promise {
+ const harness = createContext();
+ const func = await wasmSplitCommand.loader();
+ await func.call(harness.context, { ...NO_FLAGS, ...flags }, input);
+ return harness.output();
+}
+
+/** A module with code, names, and DWARF, but no build id. */
+function debugModule(): Uint8Array {
+ return wasmModule([
+ section(CODE_SECTION_ID, fromHex("0102030405")),
+ customSection("name", fromHex("deadbeef")),
+ customSection(".debug_info", fromHex("cafebabe")),
+ ]);
+}
+
+async function writeModule(bytes: Uint8Array): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "wasm-split-"));
+ const path = join(dir, "app.wasm");
+ await writeFile(path, bytes);
+ return path;
+}
+
+/** Names of the custom sections in a file on disk, in order. */
+async function customNames(path: string): Promise<(string | undefined)[]> {
+ return parseSections(await readFile(path))
+ .filter((entry) => entry.id === 0)
+ .map((entry) => entry.name);
+}
+
+describe("stdout contract", () => {
+ test("prints only the lowercase hex build id", async () => {
+ const buildId = "a1b2c3d4-e5f6-4788-99aa-bbccddeeff00";
+ const output = await run(
+ { "build-id": buildId },
+ await writeModule(debugModule())
+ );
+
+ expect(output).toBe("a1b2c3d4e5f6478899aabbccddeeff00\n");
+ });
+
+ test("--quiet suppresses stdout entirely", async () => {
+ const path = await writeModule(debugModule());
+ expect(await run({ quiet: true }, path)).toBe("");
+ // The work still happened.
+ expect(await customNames(path)).toContain("build_id");
+ });
+
+ test("--quiet cannot be used with --json", async () => {
+ const path = await writeModule(debugModule());
+ await expect(run({ quiet: true, json: true }, path)).rejects.toThrow(
+ ValidationError
+ );
+ });
+
+ test("--json reports the paths alongside the id", async () => {
+ const path = await writeModule(debugModule());
+ const output = await run(
+ { json: true, "build-id": "a1b2c3d4-e5f6-4788-99aa-bbccddeeff00" },
+ path
+ );
+
+ expect(JSON.parse(output)).toMatchObject({
+ buildId: "a1b2c3d4e5f6478899aabbccddeeff00",
+ input: path,
+ output: path,
+ });
+ });
+});
+
+describe("build id", () => {
+ test("mints one when the module has none", async () => {
+ const path = await writeModule(debugModule());
+ const output = await run({}, path);
+
+ expect(output.trim()).toMatch(/^[0-9a-f]{32}$/);
+ expect(
+ formatBuildId(
+ buildIdFromSections(parseSections(await readFile(path))) as Uint8Array
+ )
+ ).toBe(output.trim());
+ });
+
+ test("rejects a --build-id that is not a UUID", async () => {
+ const path = await writeModule(debugModule());
+ await expect(run({ "build-id": "not-a-uuid" }, path)).rejects.toThrow(
+ /Invalid --build-id/
+ );
+ });
+
+ test("rejects a missing input file", async () => {
+ await expect(
+ run({}, join(tmpdir(), "definitely-absent.wasm"))
+ ).rejects.toThrow(/does not exist/);
+ });
+});
+
+describe("writing", () => {
+ test("modifies the input in place when --out is absent", async () => {
+ const path = await writeModule(debugModule());
+ await run({ strip: true }, path);
+
+ expect(await customNames(path)).toEqual(["name", "build_id"]);
+ });
+
+ test("writes elsewhere and leaves the input alone when --out is given", async () => {
+ const path = await writeModule(debugModule());
+ const original = await readFile(path);
+ const out = join(path, "..", "out.wasm");
+
+ await run({ strip: true, out }, path);
+
+ expect(toHex(await readFile(path))).toBe(toHex(original));
+ expect(await customNames(out)).toEqual(["name", "build_id"]);
+ });
+
+ test("does not touch the file when nothing changed", async () => {
+ const bytes = wasmModule([
+ section(CODE_SECTION_ID, fromHex("01")),
+ customSection("build_id", byteVector(fromHex("00".repeat(16)))),
+ ]);
+ const path = await writeModule(bytes);
+ const before = await stat(path);
+
+ await run({}, path);
+
+ const after = await stat(path);
+ expect(after.mtimeMs).toBe(before.mtimeMs);
+ expect(toHex(await readFile(path))).toBe(toHex(bytes));
+ });
+});
+
+describe("splitting", () => {
+ test("--debug-out writes a companion that keeps the code section", async () => {
+ const path = await writeModule(debugModule());
+ const debugOut = join(path, "..", "app.debug.wasm");
+
+ await run({ "debug-out": debugOut, strip: true }, path);
+
+ const companion = parseSections(await readFile(debugOut));
+ expect(companion.some((entry) => entry.id === CODE_SECTION_ID)).toBe(true);
+ expect(companion.map((entry) => entry.name)).toContain(".debug_info");
+ // The deployable module lost its DWARF, the companion kept it.
+ expect(await customNames(path)).toEqual([
+ "name",
+ "build_id",
+ "external_debug_info",
+ ]);
+ });
+});
+
+describe("external_debug_info", () => {
+ test("falls back to the basename of --debug-out", async () => {
+ const path = await writeModule(debugModule());
+ const debugOut = join(path, "..", "nested", "..", "app.debug.wasm");
+
+ await run({ "debug-out": debugOut }, path);
+
+ expect(await readExternalDebugInfo(path)).toBe("app.debug.wasm");
+ });
+
+ test("--external-dwarf-url wins over the basename", async () => {
+ const path = await writeModule(debugModule());
+
+ await run(
+ {
+ "debug-out": join(path, "..", "app.debug.wasm"),
+ "external-dwarf-url": "https://cdn.example/debug/app.debug.wasm",
+ },
+ path
+ );
+
+ expect(await readExternalDebugInfo(path)).toBe(
+ "https://cdn.example/debug/app.debug.wasm"
+ );
+ });
+});
+
+/** Read the `external_debug_info` value out of a file on disk. */
+async function readExternalDebugInfo(path: string): Promise {
+ const found = parseSections(await readFile(path)).find(
+ (entry) => entry.name === "external_debug_info"
+ );
+ return found?.contents ? readByteVectorString(found.contents) : null;
+}
diff --git a/packages/cli/test/lib/wasm/binary.test.ts b/packages/cli/test/lib/wasm/binary.test.ts
new file mode 100644
index 000000000..ce9dc6718
--- /dev/null
+++ b/packages/cli/test/lib/wasm/binary.test.ts
@@ -0,0 +1,230 @@
+/**
+ * Tests for the wasm binary envelope.
+ *
+ * The headline requirement is round-trip fidelity: parsing a module and
+ * re-encoding it unchanged must reproduce the input byte-for-byte, including
+ * sections this parser does not understand. `wasm-split` shipped a bug here
+ * once (symbolicator#311), and it is silent until symbolication fails.
+ */
+
+import { describe, expect, test } from "vitest";
+import {
+ decodeBuildId,
+ encodeModule,
+ isDebugSection,
+ isNameSection,
+ makeBuildIdSection,
+ makeExternalDebugInfoSection,
+ parseSections,
+ readVarUint32,
+ WasmParseError,
+ writeVarUint32,
+} from "../../../src/lib/wasm/binary.js";
+import {
+ byteVector,
+ CODE_SECTION_ID,
+ concat,
+ customSection,
+ DATA_COUNT_SECTION_ID,
+ fromHex,
+ section,
+ toHex,
+ WASM_HEADER,
+ wasmModule,
+} from "./helpers.js";
+
+describe("readVarUint32 / writeVarUint32", () => {
+ test.each([
+ 0, 1, 127, 128, 624_485, 0xff_ff_ff_ff,
+ ])("round-trips %i", (value) => {
+ const encoded = writeVarUint32(value);
+ expect(readVarUint32(encoded, 0)).toEqual({
+ value,
+ size: encoded.length,
+ });
+ });
+
+ test("reads a padded, non-canonical encoding", () => {
+ // 0x01 spread over four groups. Legal, and some toolchains emit it.
+ expect(readVarUint32(fromHex("81808000"), 0)).toEqual({
+ value: 1,
+ size: 4,
+ });
+ });
+
+ test.each([
+ ["truncated", "80"],
+ ["longer than five groups", "8080808080"],
+ ["above 32 bits", "8080808010"],
+ ])("rejects an encoding that is %s", (_label, hex) => {
+ expect(() => readVarUint32(fromHex(hex), 0)).toThrow(WasmParseError);
+ });
+});
+
+describe("parseSections", () => {
+ test.each([
+ ["a buffer that is not a wasm module", fromHex("6e6f742d7761736d")],
+ ["a buffer too short to hold a header", fromHex("0061736d")],
+ [
+ "a section that runs past the end of the buffer",
+ concat([WASM_HEADER, fromHex("0020"), fromHex("0102")]),
+ ],
+ ])("rejects %s", (_label, bytes) => {
+ expect(() => parseSections(bytes)).toThrow(WasmParseError);
+ });
+
+ test("names custom sections and exposes their bodies", () => {
+ const sections = parseSections(
+ wasmModule([
+ customSection("build_id", byteVector(fromHex("00".repeat(16)))),
+ ])
+ );
+ expect(sections).toHaveLength(1);
+ expect(sections[0]?.id).toBe(0);
+ expect(sections[0]?.name).toBe("build_id");
+ expect(toHex(sections[0]?.contents as Uint8Array)).toBe(
+ `10${"00".repeat(16)}`
+ );
+ });
+
+ test("keeps a custom section whose name is unreadable, without a name", () => {
+ // Name claims 200 bytes inside a 4-byte section.
+ const malformed = section(0, fromHex("c8017f7f"));
+ const sections = parseSections(wasmModule([malformed]));
+ expect(sections).toHaveLength(1);
+ expect(sections[0]?.name).toBeUndefined();
+ expect(toHex(encodeModule(sections))).toBe(toHex(wasmModule([malformed])));
+ });
+});
+
+/**
+ * Which modules are refused, and which are waved through.
+ *
+ * Calibrated against `wasmbin`, the parser behind the Rust `wasm-split`, rather
+ * than a full validator, and checked against the Rust binary itself. Sections
+ * sort by spec rank and not by id, so the cases that pin that table down count
+ * for as much as the ones that reject.
+ */
+describe("section id and order validation", () => {
+ test.each([
+ ["an id no released spec defines", [section(0x7a, fromHex("ff00ff"))]],
+ [
+ "sections out of order",
+ [section(3, fromHex("0100")), section(1, fromHex("60000000"))],
+ ],
+ [
+ "a non-custom section twice",
+ [section(1, fromHex("00")), section(1, fromHex("00"))],
+ ],
+ [
+ "data count after code",
+ [
+ section(CODE_SECTION_ID, fromHex("00")),
+ section(DATA_COUNT_SECTION_ID, fromHex("01")),
+ ],
+ ],
+ ])("rejects %s", (_label, sections) => {
+ expect(() => parseSections(wasmModule(sections))).toThrow(WasmParseError);
+ });
+
+ test.each([
+ [
+ "data count before code",
+ [
+ section(DATA_COUNT_SECTION_ID, fromHex("01")),
+ section(CODE_SECTION_ID, fromHex("00")),
+ ],
+ ],
+ [
+ "an exception tag between memory and global",
+ [
+ section(5, fromHex("00")),
+ section(13, fromHex("00")),
+ section(6, fromHex("00")),
+ ],
+ ],
+ ])("accepts %s", (_label, sections) => {
+ expect(() => parseSections(wasmModule(sections))).not.toThrow();
+ });
+
+ test("accepts what a full validator rejects, as the Rust tool does", () => {
+ // A function with no type section to give it a signature: junk to a
+ // validator, an ordinary envelope to `wasmbin`. Guards against anyone
+ // reaching for `WebAssembly.validate` here.
+ const input = wasmModule([section(3, fromHex("0100"))]);
+ expect(WebAssembly.validate(input)).toBe(false);
+ expect(parseSections(input)).toHaveLength(1);
+ });
+});
+
+describe("round-trip fidelity", () => {
+ test("preserves a module of named and opaque sections", () => {
+ const input = wasmModule([
+ section(1, fromHex("60000000")),
+ customSection("name", fromHex("deadbeef")),
+ section(DATA_COUNT_SECTION_ID, fromHex("01")),
+ section(CODE_SECTION_ID, fromHex("01020304")),
+ customSection(".debug_info", fromHex("cafebabe")),
+ ]);
+ expect(toHex(encodeModule(parseSections(input)))).toBe(toHex(input));
+ });
+
+ test("preserves a non-canonical section length prefix", () => {
+ // A padded length would be rewritten canonically by a re-encoder that did
+ // not retain the original bytes, changing the file for no reason.
+ const input = wasmModule([section(CODE_SECTION_ID, fromHex("0102"), 4)]);
+ expect(input).toContain(0x80);
+ expect(toHex(encodeModule(parseSections(input)))).toBe(toHex(input));
+ });
+});
+
+describe("custom section encoding", () => {
+ test("build_id matches the known-good byte layout", () => {
+ const buildId = fromHex("000102030405060708090a0b0c0d0e0f");
+ const encoded = encodeModule([makeBuildIdSection(buildId)]);
+ expect(toHex(encoded.subarray(WASM_HEADER.length))).toBe(
+ // id 0 | payload 26 | name len 8 | "build_id" | vec len 16 | 16 bytes
+ "001a086275696c645f696410000102030405060708090a0b0c0d0e0f"
+ );
+ });
+
+ test("external_debug_info matches the known-good byte layout", () => {
+ const encoded = encodeModule([
+ makeExternalDebugInfoSection("app.debug.wasm"),
+ ]);
+ expect(toHex(encoded.subarray(WASM_HEADER.length))).toBe(
+ // id 0 | payload 35 | name len 19 | name | str len 14 | "app.debug.wasm"
+ "00231365787465726e616c5f64656275675f696e666f0e6170702e64656275672e7761736d"
+ );
+ });
+});
+
+describe("decodeBuildId", () => {
+ test.each([
+ ["a length prefix that disagrees with the body", "200102"],
+ ["a truncated length prefix", "80"],
+ ["trailing bytes after the vector", "0201020304"],
+ ])("returns null for %s", (_label, hex) => {
+ expect(decodeBuildId(fromHex(hex))).toBeNull();
+ });
+});
+
+describe("section predicates", () => {
+ test("classifies debug, name, and ordinary sections", () => {
+ const [debugSection, nameSection, producers, code] = parseSections(
+ wasmModule([
+ customSection(".debug_line", fromHex("00")),
+ customSection("name", fromHex("00")),
+ customSection("producers", fromHex("00")),
+ section(CODE_SECTION_ID, fromHex("00")),
+ ])
+ );
+ expect(isDebugSection(debugSection as never)).toBe(true);
+ expect(isNameSection(debugSection as never)).toBe(false);
+ expect(isNameSection(nameSection as never)).toBe(true);
+ expect(isDebugSection(nameSection as never)).toBe(false);
+ expect(isDebugSection(producers as never)).toBe(false);
+ expect(isDebugSection(code as never)).toBe(false);
+ expect(isNameSection(code as never)).toBe(false);
+ });
+});
diff --git a/packages/cli/test/lib/wasm/build-id.test.ts b/packages/cli/test/lib/wasm/build-id.test.ts
new file mode 100644
index 000000000..c2effe6e0
--- /dev/null
+++ b/packages/cli/test/lib/wasm/build-id.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Tests for the `build_id` custom section.
+ */
+
+import { describe, expect, test } from "vitest";
+import { parseSections } from "../../../src/lib/wasm/binary.js";
+import {
+ buildIdFromSections,
+ formatBuildId,
+ randomBuildId,
+ uuidToBytes,
+} from "../../../src/lib/wasm/build-id.js";
+import {
+ byteVector,
+ CODE_SECTION_ID,
+ customSection,
+ fromHex,
+ section,
+ toHex,
+ WASM_HEADER,
+ wasmModule,
+} from "./helpers.js";
+
+/** A module carrying the given build id. */
+function moduleWithBuildId(buildId: Uint8Array): Uint8Array {
+ return wasmModule([
+ section(CODE_SECTION_ID, fromHex("01020304")),
+ customSection("build_id", byteVector(buildId)),
+ ]);
+}
+
+describe("uuidToBytes", () => {
+ test.each([
+ ["canonical", "a1b2c3d4-e5f6-4788-99aa-bbccddeeff00"],
+ ["unhyphenated and uppercase", "A1B2C3D4E5F6478899AABBCCDDEEFF00"],
+ ])("parses a %s UUID", (_label, uuid) => {
+ expect(toHex(uuidToBytes(uuid) as Uint8Array)).toBe(
+ "a1b2c3d4e5f6478899aabbccddeeff00"
+ );
+ });
+
+ test.each([
+ ["too short", "a1b2c3d4"],
+ ["not hex", "a1b2c3d4-e5f6-4788-99aa-bbccddeeffzz"],
+ ["empty", ""],
+ ])("rejects a %s value", (_label, value) => {
+ expect(uuidToBytes(value)).toBeNull();
+ });
+});
+
+describe("formatBuildId", () => {
+ test("prints an id that is not a UUID, as the Rust tool does", () => {
+ expect(formatBuildId(fromHex("0102030405"))).toBe("0102030405");
+ });
+});
+
+describe("randomBuildId", () => {
+ test("mints a distinct v4 UUID each time", () => {
+ // Version nibble 4 and variant nibble 8-b, at bytes 6 and 8.
+ const first = formatBuildId(randomBuildId());
+ expect(first).toMatch(/^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$/);
+ expect(first).not.toBe(formatBuildId(randomBuildId()));
+ });
+});
+
+describe("buildIdFromSections", () => {
+ test("finds the id in a module that has one", () => {
+ const buildId = fromHex("000102030405060708090a0b0c0d0e0f");
+ const found = buildIdFromSections(
+ parseSections(moduleWithBuildId(buildId))
+ );
+ expect(toHex(found as Uint8Array)).toBe(toHex(buildId));
+ });
+
+ test("returns null for a module with no build_id", () => {
+ expect(buildIdFromSections(parseSections(WASM_HEADER))).toBeNull();
+ });
+
+ test("returns null when the build_id body is malformed", () => {
+ const broken = wasmModule([customSection("build_id", fromHex("20ff"))]);
+ expect(buildIdFromSections(parseSections(broken))).toBeNull();
+ });
+
+ test("takes the first id when a module carries two", () => {
+ // The Rust tool's `find_map` stops at the first; a module should never
+ // have two, but the tie must break the same way in both tools.
+ const duplicated = wasmModule([
+ customSection("build_id", byteVector(fromHex("11".repeat(16)))),
+ customSection("build_id", byteVector(fromHex("22".repeat(16)))),
+ ]);
+ expect(
+ toHex(buildIdFromSections(parseSections(duplicated)) as Uint8Array)
+ ).toBe("11".repeat(16));
+ });
+
+ test("skips a malformed section to reach a readable one", () => {
+ // Reusing the good id is what keeps already-uploaded debug files matched.
+ const mixed = wasmModule([
+ customSection("build_id", fromHex("20ff")),
+ customSection("build_id", byteVector(fromHex("33".repeat(16)))),
+ ]);
+ expect(toHex(buildIdFromSections(parseSections(mixed)) as Uint8Array)).toBe(
+ "33".repeat(16)
+ );
+ });
+
+ test("ignores a malformed section that follows a readable one", () => {
+ const mixed = wasmModule([
+ customSection("build_id", byteVector(fromHex("44".repeat(16)))),
+ customSection("build_id", fromHex("20ff")),
+ ]);
+ expect(toHex(buildIdFromSections(parseSections(mixed)) as Uint8Array)).toBe(
+ "44".repeat(16)
+ );
+ });
+});
diff --git a/packages/cli/test/lib/wasm/helpers.ts b/packages/cli/test/lib/wasm/helpers.ts
new file mode 100644
index 000000000..631f5e4b2
--- /dev/null
+++ b/packages/cli/test/lib/wasm/helpers.ts
@@ -0,0 +1,159 @@
+/**
+ * Byte-level helpers for building wasm fixtures.
+ *
+ * These deliberately assemble modules by hand rather than through
+ * `src/lib/wasm/binary.ts`, so a bug in the encoder cannot hide behind a
+ * fixture built with the same bug.
+ */
+
+/** Magic bytes and version that open every WebAssembly module. */
+export const WASM_HEADER = Uint8Array.from([
+ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
+]);
+
+/**
+ * Section id of the Code section.
+ *
+ * DWARF offsets are relative to it, so a debug companion that drops the code
+ * section cannot be symbolicated. That is what the companion fixtures assert.
+ */
+export const CODE_SECTION_ID = 10;
+
+/**
+ * Section id of the Data count section.
+ *
+ * Numbered after the code section but required to precede it, which is why the
+ * section order cannot be checked by comparing ids.
+ */
+export const DATA_COUNT_SECTION_ID = 12;
+
+/** Parse a hex string into bytes. */
+export function fromHex(hex: string): Uint8Array {
+ const bytes = new Uint8Array(hex.length / 2);
+ for (let index = 0; index < bytes.length; index++) {
+ bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16);
+ }
+ return bytes;
+}
+
+/** Render bytes as lowercase hex. */
+export function toHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+/** Join byte ranges into one buffer. */
+export function concat(parts: Uint8Array[]): Uint8Array {
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const part of parts) {
+ out.set(part, offset);
+ offset += part.length;
+ }
+ return out;
+}
+
+/**
+ * Encode an unsigned LEB128 integer, optionally padded.
+ *
+ * Padding produces a non-canonical but legal encoding, which some toolchains
+ * emit and which a faithful round-trip must preserve.
+ */
+// biome-ignore-start lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
+export function leb128(value: number, padTo = 0): Uint8Array {
+ const bytes: number[] = [];
+ let remaining = value;
+ do {
+ bytes.push(remaining & 0x7f);
+ remaining >>>= 7;
+ } while (remaining !== 0);
+ // Padding appends zero groups, which only a continuation flag makes legal.
+ while (bytes.length < padTo) {
+ bytes.push(0x00);
+ }
+ for (let index = 0; index < bytes.length - 1; index++) {
+ bytes[index] = (bytes[index] as number) | 0x80;
+ }
+ return Uint8Array.from(bytes);
+}
+// biome-ignore-end lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
+
+/** Encode one section: id, length prefix, payload. */
+export function section(
+ id: number,
+ payload: Uint8Array,
+ padLengthTo = 0
+): Uint8Array {
+ return concat([
+ Uint8Array.from([id]),
+ leb128(payload.length, padLengthTo),
+ payload,
+ ]);
+}
+
+/** Encode a custom section: id 0, then a length-prefixed name and a body. */
+export function customSection(
+ name: string,
+ contents: Uint8Array,
+ padLengthTo = 0
+): Uint8Array {
+ const nameBytes = new TextEncoder().encode(name);
+ return section(
+ 0,
+ concat([leb128(nameBytes.length), nameBytes, contents]),
+ padLengthTo
+ );
+}
+
+/** Wrap sections in a module header. */
+export function wasmModule(sections: Uint8Array[]): Uint8Array {
+ return concat([WASM_HEADER, ...sections]);
+}
+
+/** Prefix bytes with their length, as a `Vec` or `String` body. */
+export function byteVector(bytes: Uint8Array): Uint8Array {
+ return concat([leb128(bytes.length), bytes]);
+}
+
+/**
+ * Read a length-prefixed UTF-8 string, as an `external_debug_info` body holds.
+ *
+ * Decoded here rather than through `src/lib/wasm/binary.ts` for the same reason
+ * the fixtures are built here: a bug in the encoder must not be able to hide
+ * behind a decoder that shares it.
+ *
+ * @returns The string, or `null` when the prefix disagrees with the body or the
+ * bytes are not valid UTF-8
+ */
+// biome-ignore-start lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
+export function readByteVectorString(contents: Uint8Array): string | null {
+ let length = 0;
+ let scale = 1;
+ let offset = 0;
+ for (;;) {
+ const byte = contents[offset];
+ if (byte === undefined) {
+ return null;
+ }
+ offset += 1;
+ length += (byte & 0x7f) * scale;
+ if ((byte & 0x80) === 0) {
+ break;
+ }
+ scale *= 128;
+ }
+ if (offset + length !== contents.length) {
+ return null;
+ }
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(
+ contents.subarray(offset, offset + length)
+ );
+ } catch {
+ // Invalid UTF-8 is an expected fixture, not a failure worth reporting.
+ return null;
+ }
+}
+// biome-ignore-end lint/suspicious/noBitwiseOperators: LEB128 is defined in terms of bit groups
diff --git a/packages/cli/test/lib/wasm/split.test.ts b/packages/cli/test/lib/wasm/split.test.ts
new file mode 100644
index 000000000..dc74e161f
--- /dev/null
+++ b/packages/cli/test/lib/wasm/split.test.ts
@@ -0,0 +1,220 @@
+/**
+ * Tests for the split primitive.
+ *
+ * These pin the parts of Symbolicator's `wasm-split` that are load-bearing but
+ * invisible: the companion is captured before stripping, and an id the module
+ * already carries always wins.
+ */
+
+import { describe, expect, test } from "vitest";
+import {
+ parseSections,
+ type WasmSection,
+} from "../../../src/lib/wasm/binary.js";
+import { buildIdFromSections } from "../../../src/lib/wasm/build-id.js";
+import { splitWasm } from "../../../src/lib/wasm/split.js";
+import {
+ byteVector,
+ CODE_SECTION_ID,
+ customSection,
+ fromHex,
+ readByteVectorString,
+ section,
+ toHex,
+ wasmModule,
+} from "./helpers.js";
+
+/** A module with code, names, and DWARF, but no build id. */
+function debugModule(): Uint8Array {
+ return wasmModule([
+ section(1, fromHex("60000000")),
+ section(CODE_SECTION_ID, fromHex("0102030405")),
+ customSection("name", fromHex("deadbeef")),
+ customSection(".debug_info", fromHex("cafebabe")),
+ customSection(".debug_line", fromHex("f00d")),
+ customSection("producers", fromHex("2a")),
+ ]);
+}
+
+/** Names of the custom sections in a module, in order. */
+function customNames(bytes: Uint8Array): (string | undefined)[] {
+ return parseSections(bytes)
+ .filter((entry: WasmSection) => entry.id === 0)
+ .map((entry) => entry.name);
+}
+
+/** Whether a module still has a code section. */
+function hasCodeSection(bytes: Uint8Array): boolean {
+ return parseSections(bytes).some((entry) => entry.id === CODE_SECTION_ID);
+}
+
+/** Value of the module's `external_debug_info` section, if it has one. */
+function externalDebugInfo(bytes: Uint8Array): string | null {
+ const found = parseSections(bytes).find(
+ (entry) => entry.name === "external_debug_info"
+ );
+ return found?.contents ? readByteVectorString(found.contents) : null;
+}
+
+describe("build id handling", () => {
+ test("reuses an id the module already carries", () => {
+ const existing = fromHex("000102030405060708090a0b0c0d0e0f");
+ const input = wasmModule([
+ section(CODE_SECTION_ID, fromHex("01")),
+ customSection("build_id", byteVector(existing)),
+ ]);
+
+ const result = splitWasm(input, {
+ buildId: fromHex("ffffffffffffffffffffffffffffffff"),
+ });
+
+ expect(toHex(result.buildId)).toBe(toHex(existing));
+ expect(result.moduleChanged).toBe(false);
+ expect(toHex(result.module)).toBe(toHex(input));
+ });
+
+ test("uses the supplied id when the module has none", () => {
+ const supplied = fromHex("0f0e0d0c0b0a09080706050403020100");
+ const result = splitWasm(debugModule(), { buildId: supplied });
+
+ expect(toHex(result.buildId)).toBe(toHex(supplied));
+ expect(result.moduleChanged).toBe(true);
+ expect(
+ toHex(buildIdFromSections(parseSections(result.module)) as Uint8Array)
+ ).toBe(toHex(supplied));
+ });
+});
+
+describe("debug companion", () => {
+ test("retains every section, including code and DWARF", () => {
+ const result = splitWasm(debugModule(), { companion: true, strip: true });
+ const companion = result.companion as Uint8Array;
+
+ expect(hasCodeSection(companion)).toBe(true);
+ expect(customNames(companion)).toEqual([
+ "name",
+ ".debug_info",
+ ".debug_line",
+ "producers",
+ "build_id",
+ ]);
+ });
+
+ test("carries the same build id as the stripped module", () => {
+ const result = splitWasm(debugModule(), { companion: true, strip: true });
+
+ expect(
+ toHex(
+ buildIdFromSections(
+ parseSections(result.companion as Uint8Array)
+ ) as Uint8Array
+ )
+ ).toBe(toHex(result.buildId));
+ expect(
+ toHex(buildIdFromSections(parseSections(result.module)) as Uint8Array)
+ ).toBe(toHex(result.buildId));
+ });
+
+ test("is absent unless requested", () => {
+ expect(splitWasm(debugModule(), { strip: true }).companion).toBeUndefined();
+ });
+});
+
+describe("stripping", () => {
+ test("--strip removes .debug_* and keeps names", () => {
+ const result = splitWasm(debugModule(), { strip: true });
+
+ expect(customNames(result.module)).toEqual([
+ "name",
+ "producers",
+ "build_id",
+ ]);
+ expect(hasCodeSection(result.module)).toBe(true);
+ });
+
+ test("--strip --strip-names also removes the name section", () => {
+ const result = splitWasm(debugModule(), { strip: true, stripNames: true });
+
+ expect(customNames(result.module)).toEqual(["producers", "build_id"]);
+ });
+
+ test("--strip-names is a no-op without --strip", () => {
+ const withFlag = splitWasm(debugModule(), {
+ stripNames: true,
+ buildId: fromHex("00".repeat(16)),
+ });
+ const without = splitWasm(debugModule(), {
+ buildId: fromHex("00".repeat(16)),
+ });
+
+ expect(customNames(withFlag.module)).toContain("name");
+ expect(toHex(withFlag.module)).toBe(toHex(without.module));
+ });
+
+ test("leaves a section whose custom name is unreadable", () => {
+ // Name claims 200 bytes inside a 4-byte section, so it is unnamed and
+ // cannot be matched against `.debug_` — the same as wasmbin, which skips
+ // custom sections whose header will not parse.
+ const malformed = section(0, fromHex("c8017f7f"));
+ const result = splitWasm(wasmModule([malformed]), { strip: true });
+
+ expect(parseSections(result.module).filter((s) => s.id === 0)).toHaveLength(
+ 2
+ );
+ });
+});
+
+describe("external_debug_info", () => {
+ test("is written when a URL is given", () => {
+ const result = splitWasm(debugModule(), {
+ externalDebugInfo: "https://cdn.example/app.debug.wasm",
+ });
+
+ expect(externalDebugInfo(result.module)).toBe(
+ "https://cdn.example/app.debug.wasm"
+ );
+ expect(result.moduleChanged).toBe(true);
+ });
+
+ test("is absent when no URL is given", () => {
+ expect(externalDebugInfo(splitWasm(debugModule()).module)).toBeNull();
+ });
+
+ test("is not written to the companion", () => {
+ const result = splitWasm(debugModule(), {
+ companion: true,
+ externalDebugInfo: "app.debug.wasm",
+ });
+
+ expect(externalDebugInfo(result.companion as Uint8Array)).toBeNull();
+ expect(externalDebugInfo(result.module)).toBe("app.debug.wasm");
+ });
+});
+
+describe("moduleChanged", () => {
+ test("is false when there is nothing to do, companion or not", () => {
+ const input = wasmModule([
+ section(CODE_SECTION_ID, fromHex("01")),
+ customSection("build_id", byteVector(fromHex("00".repeat(16)))),
+ ]);
+
+ const result = splitWasm(input);
+
+ expect(result.moduleChanged).toBe(false);
+ expect(toHex(result.module)).toBe(toHex(input));
+ // Requesting a companion reads the module but never rewrites it.
+ expect(splitWasm(input, { companion: true }).moduleChanged).toBe(false);
+ });
+
+ test("is false when stripping finds nothing to strip", () => {
+ const input = wasmModule([
+ section(CODE_SECTION_ID, fromHex("01")),
+ customSection("build_id", byteVector(fromHex("00".repeat(16)))),
+ ]);
+
+ const result = splitWasm(input, { strip: true });
+
+ expect(result.moduleChanged).toBe(false);
+ expect(toHex(result.module)).toBe(toHex(input));
+ });
+});