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)); + }); +});