diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 3e932f7ccf..773db426f6 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -60,7 +60,7 @@ cli/ │ │ ├── code-mappings/# upload │ │ ├── dart-symbol-map/# upload │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view -│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, print-sources, upload +│ │ ├── debug-files/ # bundle-jvm, bundle-sources, check, find, prepare, print-sources, upload │ │ ├── docs/ # list, query │ │ ├── event/ # list, send, view │ │ ├── feedback/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/debug-files.md b/apps/cli-docs/src/fragments/commands/debug-files.md index 979839c150..1051030547 100644 --- a/apps/cli-docs/src/fragments/commands/debug-files.md +++ b/apps/cli-docs/src/fragments/commands/debug-files.md @@ -49,8 +49,43 @@ sentry debug-files upload ./build --il2cpp-mapping --include-sources # Preview what would be uploaded without uploading (no credentials needed) sentry debug-files upload ./build --no-upload + +# Split WebAssembly debug info and upload it (scans directories recursively) +sentry debug-files prepare ./dist + +# Preview the split without writing or uploading anything +sentry debug-files prepare ./dist --dry-run + +# Split only, keeping the companions local +sentry debug-files prepare ./dist --no-upload + +# Write companions elsewhere; modules are still stripped in place +sentry debug-files prepare ./dist --out-dir ./symbols + +# Fail the build if any module was compiled without DWARF +sentry debug-files prepare ./dist --require-dwarf ``` +## Notes on `prepare` + +- For each module carrying inline DWARF it injects a `build_id` (if absent), + writes a `*.debug.wasm` companion retaining the Code section and DWARF, + strips the `.debug_*` sections from the deployable module **in place**, and + points it at the companion via `external_debug_info`. Your build artifact + keeps its path; only the companion is new. +- The companion must keep the Code section — DWARF addresses are relative to it, + so a companion without it cannot be symbolicated. +- Modules without DWARF are still stamped with a `build_id` and reported with a + warning rather than failing the run. Sentry matches a frame to its debug file + by `build_id`, so stamping now keeps symbolication possible later. +- Name/symtab-only modules are not uploaded: the `name` section stays in the + deployable module and runtimes read function names from it directly, so a + debug file built from one adds nothing to the stack trace. +- The command is idempotent. +- `--require-dwarf` exits non-zero when any scanned module lacks DWARF, which is + the flag to use in CI. A module whose `external_debug_info` names a companion + that cannot be found fails the gate too, since its debug info is unreachable. + ## Notes on `find` - `debug-files find` locates debug files **locally** by debug identifier — it 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 9fb20ac1f2..c502351df1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -479,6 +479,7 @@ Work with debug information files - `sentry debug-files check ` — Inspect a debug information file - `sentry debug-files find ` — Locate debug files for given debug identifiers +- `sentry debug-files prepare ` — Split WebAssembly debug info and upload it to Sentry - `sentry debug-files upload ` — Upload debug information files to Sentry - `sentry debug-files print-sources ` — List the source files a debug file references - `sentry debug-files bundle-sources ` — Bundle a debug file's source files for source context diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 4daef1ece0..88ebaebd78 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -25,6 +25,23 @@ Locate debug files for given debug identifiers - `--no-cwd - Do not look for debug files in the current directory` - `-p, --path ... - Add a directory to search recursively (repeatable)` +### `sentry debug-files prepare ` + +Split WebAssembly debug info and upload it to Sentry + +**Flags:** +- `--dry-run - Classify modules without writing or uploading anything` +- `--no-upload - Split modules but do not upload the companions` +- `--require-dwarf - Fail if any scanned module lacks DWARF debug info` +- `--out-dir - Directory for *.debug.wasm companions (modules are stripped in place)` +- `--strip-names - Also drop the name section from split modules (companion keeps it)` +- `--build-id - Use this UUID as the build id instead of a random one (one .wasm file only)` +- `--include-sources - Also upload a source bundle for each companion` +- `--ignore ... - Skip files and folders matching this glob (repeatable)` +- `--ignore-file - Skip files and folders listed in this ignore file` +- `--wait - Wait for server-side processing and report any errors` +- `--wait-for - Wait up to this many seconds for server-side processing` + ### `sentry debug-files upload ` Upload debug information files to Sentry @@ -112,6 +129,21 @@ sentry debug-files upload ./build --il2cpp-mapping --include-sources # Preview what would be uploaded without uploading (no credentials needed) sentry debug-files upload ./build --no-upload + +# Split WebAssembly debug info and upload it (scans directories recursively) +sentry debug-files prepare ./dist + +# Preview the split without writing or uploading anything +sentry debug-files prepare ./dist --dry-run + +# Split only, keeping the companions local +sentry debug-files prepare ./dist --no-upload + +# Write companions elsewhere; modules are still stripped in place +sentry debug-files prepare ./dist --out-dir ./symbols + +# Fail the build if any module was compiled without DWARF +sentry debug-files prepare ./dist --require-dwarf ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 232567746b..892f097db0 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -14,7 +14,6 @@ * bundled `symbolic` WASM module (see `src/lib/dif/`). */ -import { readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; @@ -28,7 +27,7 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; -import { readDebugFile } from "./read-file.js"; +import { readDebugFile, readSourceFile } from "./read-file.js"; const log = logger.withTag("debug-files.bundle-sources"); @@ -119,17 +118,7 @@ export const bundleSourcesCommand = buildCommand({ result = createSourceBundle( new Uint8Array(content), basename(path), - (sourcePath) => { - try { - return readFileSync(sourcePath); - } catch (err) { - log.debug( - `Source file not available, skipping: ${sourcePath}`, - err - ); - return null; - } - } + readSourceFile ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/commands/debug-files/index.ts b/packages/cli/src/commands/debug-files/index.ts index 6584544a5a..54f168f786 100644 --- a/packages/cli/src/commands/debug-files/index.ts +++ b/packages/cli/src/commands/debug-files/index.ts @@ -9,6 +9,7 @@ import { bundleJvmCommand } from "./bundle-jvm.js"; import { bundleSourcesCommand } from "./bundle-sources.js"; import { checkCommand } from "./check.js"; import { findCommand } from "./find.js"; +import { prepareCommand } from "./prepare.js"; import { printSourcesCommand } from "./print-sources.js"; import { uploadCommand } from "./upload.js"; @@ -16,6 +17,7 @@ export const debugFilesRoute = buildRouteMap({ routes: { check: checkCommand, find: findCommand, + prepare: prepareCommand, upload: uploadCommand, "print-sources": printSourcesCommand, "bundle-sources": bundleSourcesCommand, diff --git a/packages/cli/src/commands/debug-files/prepare.ts b/packages/cli/src/commands/debug-files/prepare.ts new file mode 100644 index 0000000000..dcffd17190 --- /dev/null +++ b/packages/cli/src/commands/debug-files/prepare.ts @@ -0,0 +1,534 @@ +/** + * sentry debug-files prepare ... + * + * Scan for WebAssembly modules, split the ones carrying inline DWARF into a + * deployable module plus a `*.debug.wasm` companion, and upload the companions + * to Sentry. + * + * This replaces the two-step `wasm-split` + `debug-files upload` workflow. The + * split matches what Symbolicator's `wasm-split` produces, so companions from + * either tool are interchangeable in Sentry. + * + * Modules that cannot be split are stamped with a `build_id` and reported with + * a warning rather than failing the run, so scanning a build directory does not + * stop at the first module without DWARF. Use `--require-dwarf` in CI to turn + * that into an error. + */ + +import { readFile, stat } from "node:fs/promises"; +import { basename, relative, resolve, sep } from "node:path"; +import type { SentryContext } from "../../context.js"; +import { + type ChunkServerOptions, + getChunkUploadOptions, +} from "../../lib/api/chunk-upload.js"; +import { + type DebugFileUpload, + uploadDebugFiles, +} from "../../lib/api/debug-files.js"; +import { buildCommand } from "../../lib/command.js"; +import { createSourceBundle } from "../../lib/dif/index.js"; +import { scanPaths } from "../../lib/dif/scan.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { formatPrepareResult } from "../../lib/formatters/wasm-prepare.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildIgnoreMatcher, normalizePath } from "../../lib/scan/index.js"; +import { debugIdFromBuildId, uuidToBytes } from "../../lib/wasm/build-id.js"; +import { + hasDwarfQuality, + isDebugCompanionPath, + isWasmPath, + type PrepareAction, + type PrepareCommandResult, + type PrepareResult, + prepareWasmFile, + uploadPath, +} from "../../lib/wasm/prepare.js"; +import { readSourceFile } from "./read-file.js"; +import { resolveWaitMode, type WaitFlags } from "./wait.js"; + +const log = logger.withTag("debug-files.prepare"); + +const USAGE_HINT = "sentry debug-files prepare ..."; + +/** Flags accepted by the prepare command. */ +type PrepareFlags = WaitFlags & { + "dry-run"?: boolean; + "no-upload"?: boolean; + "require-dwarf"?: boolean; + "out-dir"?: string; + "strip-names"?: boolean; + "build-id"?: string; + "include-sources"?: boolean; + ignore?: string[]; + "ignore-file"?: string; +}; + +/** + * Parse the explicit `--build-id` value into raw bytes. + * + * @throws {ValidationError} If the value is not a UUID. + */ +function resolveBuildId(value: string | undefined): Uint8Array | undefined { + if (value === undefined) { + return; + } + const bytes = uuidToBytes(value); + if (!bytes) { + throw new ValidationError( + `--build-id must be a UUID, got '${value}'`, + "build-id" + ); + } + return bytes; +} + +/** + * Whether a module fails the `--require-dwarf` gate. + * + * Only skipped modules can fail: a split or already-prepared module reached the + * end state the flag is checking for. + * + * A skipped `external_debug_info` module is a dangling pointer by + * construction — had its companion been found with a matching build id, it + * would have been reported as already-prepared. The DWARF exists somewhere, + * but nothing reachable was uploaded, so the build symbolicates no better than + * one compiled without debug info and the gate has to catch it. + */ +export function lacksDwarf(result: PrepareResult): boolean { + if (result.action !== "skipped") { + return false; + } + return ( + result.quality === "external-debug-info" || !hasDwarfQuality(result.quality) + ); +} + +/** Yield a result that uploaded nothing, then close with the given hint. */ +function* reportWithoutUpload(params: { + results: PrepareResult[]; + hint: string; +}) { + yield new CommandOutput({ + uploaded: false, + modules: params.results, + filesUploaded: 0, + }); + return { hint: params.hint }; +} + +/** Upload the companions, yield the combined result, and pick a hint. */ +async function* reportUpload( + setExitCode: (code: number) => void, + params: { + org: string; + project: string; + difs: DebugFileUpload[]; + results: PrepareResult[]; + wait: boolean; + maxWaitMs: number; + serverOptions: ChunkServerOptions; + } +) { + const uploads = await uploadDebugFiles(params); + const failures = uploads.filter( + (r) => r.state === "error" || r.state === "not_found" + ); + + yield new CommandOutput({ + org: params.org, + project: params.project, + uploaded: true, + modules: params.results, + filesUploaded: uploads.length - failures.length, + }); + + if (failures.length > 0) { + setExitCode(1); + const details = failures + .map((r) => `${r.debugId ?? r.name}: ${r.state}`) + .join("; "); + return { hint: `${failures.length} companion(s) failed: ${details}` }; + } + + return { + hint: `Uploaded ${uploads.length} debug companion(s) to ${params.org}/${params.project}`, + }; +} + +const COMPANION_PRODUCING_ACTIONS = new Set([ + "split", + "would-split", + "already-prepared", +]); + +/** + * Whether a module has a companion, or would get one on a real run. + * + * Unlike {@link uploadPath} this counts `would-split`, so a dry run reports the + * work it would do rather than the (always empty) set it would upload. + */ +function producesCompanion(result: PrepareResult): boolean { + return COMPANION_PRODUCING_ACTIONS.has(result.action); +} + +/** + * Append a source bundle for a companion, when it references sources available + * on this machine. + * + * The companion is where the DWARF lives, so it is the only artifact that can + * name the source files to collect. Failures are logged and swallowed so a + * missing source tree never aborts the upload. + */ +function appendSourceBundle( + difs: DebugFileUpload[], + companion: Uint8Array, + name: string, + debugId: string | undefined +): void { + let result: ReturnType; + try { + result = createSourceBundle(companion, name, readSourceFile); + } catch (err) { + log.debug(`Could not build source bundle for ${name}`, err); + return; + } + if (!(result.bundle && result.fileCount > 0)) { + return; + } + const bundleDebugId = debugId ?? result.debugId ?? undefined; + difs.push({ + name: `${bundleDebugId ?? name}.src.zip`, + debugId: bundleDebugId, + content: Buffer.from(result.bundle), + }); +} + +/** Read each companion from disk and turn it into an upload entry. */ +async function collectCompanionDifs( + results: PrepareResult[], + includeSources: boolean +): Promise { + const difs: DebugFileUpload[] = []; + for (const result of results) { + const path = uploadPath(result); + if (!path) { + continue; + } + const content = await readFile(path); + // The server re-parses the bytes and indexes every slice itself, so the id + // is advisory; send it in the canonical dashed form or not at all. + const debugId = result.buildId + ? debugIdFromBuildId(result.buildId) + : undefined; + difs.push({ name: basename(path), debugId, content }); + + if (includeSources) { + appendSourceBundle( + difs, + new Uint8Array(content), + basename(path), + debugId + ); + } + } + return difs; +} + +/** + * Path of a scanned file relative to the scan root it came from. + * + * Ignore patterns are written against the tree the user pointed at — `--ignore + * 'vendor/**'` for `prepare ./dist` means `./dist/vendor` — so they must be + * tested against a root-relative path rather than one relative to the process + * working directory. Separators are normalized to `/` because the `ignore` + * package only understands POSIX paths. + */ +function pathRelativeToRoot(path: string, roots: string[]): string { + const resolved = resolve(path); + let deepestRoot = ""; + for (const root of roots) { + const rootPath = resolve(root); + const contains = + resolved === rootPath || resolved.startsWith(`${rootPath}${sep}`); + if (contains && rootPath.length > deepestRoot.length) { + deepestRoot = rootPath; + } + } + // A root naming the file itself leaves nothing relative to match on, so fall + // back to the basename. + const rel = deepestRoot ? relative(deepestRoot, resolved) : ""; + return normalizePath(rel === "" ? basename(resolved) : rel); +} + +/** + * Reject an explicitly named file that is not a WebAssembly module. + * + * A directory is scanned recursively and simply yields nothing, but naming a + * non-`.wasm` file directly is a mistake worth reporting rather than silently + * treating as an empty scan. + * + * @throws {ValidationError} If an explicit path is a file without a `.wasm` + * extension. + */ +async function assertWasmPaths(paths: string[]): Promise { + for (const path of paths) { + const info = await stat(path).catch(() => null); + if (info?.isFile() && !isWasmPath(path)) { + throw new ValidationError( + `Expected a .wasm file or a directory, but got ${path}`, + "path" + ); + } + } +} + +/** + * Reject `--build-id` for anything but one explicitly named module. + * + * The flag supplies a single id, and every module that needs stamping would + * receive it. Sentry matches a stack frame to its debug file by build id, so + * modules sharing one are indistinguishable and neither symbolicates + * reliably. A directory can always hold more than one module, which is why it + * is refused even when it currently holds exactly one. + * + * @throws {ValidationError} If several paths were given, or the single path is + * not a file. + */ +async function assertSingleModuleForBuildId(paths: string[]): Promise { + const [path, ...rest] = paths; + // A path that does not exist is left to the scan, which names it in its own + // error rather than blaming the flag. + const info = path ? await stat(path).catch(() => null) : null; + if (rest.length > 0 || info?.isDirectory()) { + throw new ValidationError( + "--build-id applies to one module: pass a single .wasm file, not a " + + "directory or several paths", + "build-id" + ); + } +} + +export const prepareCommand = buildCommand({ + // Auth is only needed on the upload path; --dry-run and --no-upload skip it. + auth: false, + docs: { + brief: "Split WebAssembly debug info and upload it to Sentry", + fullDescription: + "Scan files and directories for WebAssembly modules, split the ones " + + "carrying inline DWARF, and upload the debug companions to Sentry.\n\n" + + "For each module with DWARF this injects a build_id (if absent), writes " + + "a *.debug.wasm companion retaining the Code section and DWARF, strips " + + "the .debug_* sections from the deployable module in place, and points " + + "it at the companion via external_debug_info. The deployable module " + + "keeps its original path, so your build artifact does not move.\n\n" + + "Modules without DWARF are stamped with a build_id and reported with a " + + "warning instead of failing the run. Running the command twice is safe: " + + "an already-prepared module is detected and left alone.\n\n" + + "Org/project are auto-detected from DSN, env vars, or config defaults.\n\n" + + "--require-dwarf is checked before anything is uploaded, so a build " + + "missing debug info fails without pushing files first. A module whose " + + "external_debug_info points at a companion that cannot be found fails " + + "too: its debug info is unreachable.\n\n" + + "--build-id names one module and is rejected for a directory or for " + + "several paths. Every module that needs stamping would take the id, and " + + "modules sharing one cannot be told apart when Sentry looks for their " + + "debug files.\n\n" + + "Usage:\n" + + " sentry debug-files prepare ./dist\n" + + " sentry debug-files prepare ./app.wasm --no-upload\n" + + " sentry debug-files prepare ./app.wasm --build-id \n" + + " sentry debug-files prepare ./dist --dry-run\n" + + " sentry debug-files prepare ./dist --require-dwarf\n" + + " sentry debug-files prepare ./dist --out-dir ./symbols\n" + + " sentry debug-files prepare ./dist --include-sources\n" + + " sentry debug-files prepare ./dist --ignore 'vendor/**'", + }, + output: { + human: formatPrepareResult, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "WebAssembly files or directories to scan", + parse: String, + placeholder: "path", + }, + }, + flags: { + "dry-run": { + kind: "boolean", + brief: "Classify modules without writing or uploading anything", + optional: true, + default: false, + }, + "no-upload": { + kind: "boolean", + brief: "Split modules but do not upload the companions", + optional: true, + default: false, + }, + "require-dwarf": { + kind: "boolean", + brief: "Fail if any scanned module lacks DWARF debug info", + optional: true, + default: false, + }, + "out-dir": { + kind: "parsed", + parse: String, + brief: + "Directory for *.debug.wasm companions (modules are stripped in place)", + optional: true, + }, + "strip-names": { + kind: "boolean", + brief: + "Also drop the name section from split modules (companion keeps it)", + optional: true, + default: false, + }, + "build-id": { + kind: "parsed", + parse: String, + brief: + "Use this UUID as the build id instead of a random one (one .wasm file only)", + optional: true, + }, + "include-sources": { + kind: "boolean", + brief: "Also upload a source bundle for each companion", + optional: true, + default: false, + }, + ignore: { + kind: "parsed", + parse: String, + brief: "Skip files and folders matching this glob (repeatable)", + optional: true, + variadic: true, + }, + "ignore-file": { + kind: "parsed", + parse: String, + brief: "Skip files and folders listed in this ignore file", + optional: true, + }, + wait: { + kind: "boolean", + brief: "Wait for server-side processing and report any errors", + optional: true, + default: false, + }, + "wait-for": { + kind: "parsed", + parse: Number, + brief: "Wait up to this many seconds for server-side processing", + optional: true, + }, + }, + }, + async *func(this: SentryContext, flags: PrepareFlags, ...paths: string[]) { + if (paths.length === 0) { + throw new ContextError("WebAssembly path(s)", USAGE_HINT, []); + } + const { wait, maxWaitMs } = resolveWaitMode(flags); + const buildId = resolveBuildId(flags["build-id"]); + const dryRun = Boolean(flags["dry-run"]); + const setExitCode = (code: number) => { + this.process.exitCode = code; + }; + + await assertWasmPaths(paths); + if (buildId) { + await assertSingleModuleForBuildId(paths); + } + const ignoreMatcher = await buildIgnoreMatcher( + flags.ignore, + flags["ignore-file"] + ); + + // Companions are inputs to a previous run, never candidates themselves. + const candidates = (await scanPaths(paths)).filter( + (path) => + isWasmPath(path) && + !isDebugCompanionPath(path) && + !ignoreMatcher?.ignores(pathRelativeToRoot(path, paths)) + ); + + if (candidates.length === 0) { + log.warn("No WebAssembly modules found."); + return yield* reportWithoutUpload({ + results: [], + hint: `No .wasm modules found. Try: ${USAGE_HINT}`, + }); + } + + const results: PrepareResult[] = []; + for (const path of candidates) { + results.push( + await prepareWasmFile(path, { + dryRun, + outDir: flags["out-dir"], + buildId, + stripNames: flags["strip-names"], + }) + ); + } + + // The DWARF gate runs before anything is uploaded: a build missing debug + // info should fail cleanly rather than push files and then go red. + const missingDwarf = results.filter(lacksDwarf); + if (flags["require-dwarf"] && missingDwarf.length > 0) { + setExitCode(1); + return yield* reportWithoutUpload({ + results, + hint: `${missingDwarf.length} module(s) have no reachable DWARF debug info (--require-dwarf).`, + }); + } + + const prepared = results.filter(producesCompanion).length; + + // A dry run reports what it found and stops before any upload. + if (dryRun || flags["no-upload"]) { + return yield* reportWithoutUpload({ + results, + hint: dryRun + ? `Would prepare ${prepared} module(s). Remove --dry-run to write them.` + : `Prepared ${prepared} module(s). Remove --no-upload to upload the companions.`, + }); + } + + const difs = await collectCompanionDifs( + results, + Boolean(flags["include-sources"]) + ); + if (difs.length === 0) { + return yield* reportWithoutUpload({ + results, + hint: "No debug companions to upload.", + }); + } + + const resolved = await resolveOrgAndProject({ + cwd: this.cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + return yield* reportUpload(setExitCode, { + org: resolved.org, + project: resolved.project, + difs, + results, + wait, + maxWaitMs, + serverOptions: await getChunkUploadOptions(resolved.org), + }); + }, +}); diff --git a/packages/cli/src/commands/debug-files/read-file.ts b/packages/cli/src/commands/debug-files/read-file.ts index ac54407b67..971062771d 100644 --- a/packages/cli/src/commands/debug-files/read-file.ts +++ b/packages/cli/src/commands/debug-files/read-file.ts @@ -2,8 +2,12 @@ * Shared file reading for `debug-files` commands. */ +import { readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { ValidationError } from "../../lib/errors.js"; +import { logger } from "../../lib/logger.js"; + +const log = logger.withTag("debug-files.read-file"); /** * Read a debug information file from disk with descriptive error handling. @@ -30,3 +34,22 @@ export async function readDebugFile(path: string): Promise { throw new ValidationError(`Cannot read file '${path}': ${msg}`, "path"); } } + +/** + * Read a source file for source-bundle resolution. + * + * A debug file names the sources it was compiled from, but those sources are + * often absent on the machine running the upload. A missing one is expected, so + * it is skipped rather than failing the bundle. + * + * @param sourcePath - Path the debug file references. + * @returns The contents, or `null` when the file is not available locally. + */ +export function readSourceFile(sourcePath: string): Uint8Array | null { + try { + return readFileSync(sourcePath); + } catch (err) { + log.debug(`Source file not available, skipping: ${sourcePath}`, err); + return null; + } +} diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index afc8b6dcf1..1c2160cb3a 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -21,7 +21,7 @@ */ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import type { SentryContext } from "../../context.js"; @@ -31,7 +31,6 @@ import { getChunkUploadOptions, } from "../../lib/api/chunk-upload.js"; import { - DEBUG_FILES_MAX_WAIT_MS, type DebugFileUpload, type DebugFileUploadResult, uploadDebugFiles, @@ -48,7 +47,7 @@ import { prepareDifs, scanPaths, } from "../../lib/dif/scan.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { ContextError } from "../../lib/errors.js"; import { colorTag, mdKvTable, @@ -57,6 +56,8 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { readSourceFile } from "./read-file.js"; +import { resolveWaitMode, type WaitFlags } from "./wait.js"; const log = logger.withTag("debug-files.upload"); @@ -125,7 +126,7 @@ type DebugFilesUploadResult = { }; /** Flags accepted by the upload command. */ -type UploadFlags = { +type UploadFlags = WaitFlags & { type?: string[]; id?: string[]; "require-all"?: boolean; @@ -137,8 +138,6 @@ type UploadFlags = { "derived-data"?: boolean; "no-zips"?: boolean; "no-upload"?: boolean; - wait?: boolean; - "wait-for"?: number; }; // ── Formatter ─────────────────────────────────────────────────────── @@ -177,19 +176,6 @@ function difKey(dif: DebugFileUpload): string { return `${dif.debugId ?? ""}:${hash}`; } -/** - * Read a source file from disk for source-bundle/IL2CPP resolution, returning - * `null` (and logging at debug level) when it is not available locally. - */ -function readSourceFile(sourcePath: string): Uint8Array | null { - try { - return readFileSync(sourcePath); - } catch (err) { - log.debug(`Source file not available, skipping: ${sourcePath}`, err); - return null; - } -} - /** * Compute a Unity IL2CPP line mapping for a prepared file and, when non-empty, * append it as a separate `il2cpp` DIF carrying the file's debug id. @@ -318,34 +304,6 @@ function missingRequestedIds( ); } -/** - * Resolve the wait mode and deadline from `--wait` / `--wait-for`. - * - * @throws {ValidationError} If both flags are set, or `--wait-for` is invalid. - */ -function resolveWaitMode(flags: UploadFlags): { - wait: boolean; - maxWaitMs: number; -} { - const waitFor = flags["wait-for"]; - if (flags.wait && waitFor !== undefined) { - throw new ValidationError( - "--wait and --wait-for cannot be combined", - "wait" - ); - } - if (waitFor !== undefined) { - if (!Number.isFinite(waitFor) || waitFor <= 0) { - throw new ValidationError( - "--wait-for must be a positive number of seconds", - "wait-for" - ); - } - return { wait: true, maxWaitMs: Math.round(waitFor * 1000) }; - } - return { wait: Boolean(flags.wait), maxWaitMs: DEBUG_FILES_MAX_WAIT_MS }; -} - // ── Command ───────────────────────────────────────────────────────── /** diff --git a/packages/cli/src/commands/debug-files/wait.ts b/packages/cli/src/commands/debug-files/wait.ts new file mode 100644 index 0000000000..1eebb46228 --- /dev/null +++ b/packages/cli/src/commands/debug-files/wait.ts @@ -0,0 +1,49 @@ +/** + * Shared `--wait` / `--wait-for` handling for `debug-files` commands. + * + * Uploading is only half the job: the server still has to assemble and index + * what it received. Commands that upload therefore offer the same pair of + * flags, and they have to agree on what the pair means. + */ + +import { DEBUG_FILES_MAX_WAIT_MS } from "../../lib/api/debug-files.js"; +import { ValidationError } from "../../lib/errors.js"; + +/** The wait flags, as a command declares them. */ +export type WaitFlags = { + /** Wait for server-side processing, using the default deadline. */ + wait?: boolean; + /** Wait for server-side processing, in seconds. */ + "wait-for"?: number; +}; + +/** Whether to wait, and for how long. */ +export type WaitMode = { + wait: boolean; + maxWaitMs: number; +}; + +/** + * Resolve the wait mode and deadline from the flags. + * + * @throws {ValidationError} If both flags are set, or `--wait-for` is invalid. + */ +export function resolveWaitMode(flags: WaitFlags): WaitMode { + const waitFor = flags["wait-for"]; + if (flags.wait && waitFor !== undefined) { + throw new ValidationError( + "--wait and --wait-for cannot be combined", + "wait" + ); + } + if (waitFor !== undefined) { + if (!Number.isFinite(waitFor) || waitFor <= 0) { + throw new ValidationError( + "--wait-for must be a positive number of seconds", + "wait-for" + ); + } + return { wait: true, maxWaitMs: Math.round(waitFor * 1000) }; + } + return { wait: Boolean(flags.wait), maxWaitMs: DEBUG_FILES_MAX_WAIT_MS }; +} diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index aca157ed5e..d4e88e5b92 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -13,10 +13,10 @@ import { renderMarkdown, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, - buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, type InjectResult, diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index c9761a5f6d..9f78a21624 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -17,9 +17,9 @@ import { renderMarkdown, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, - buildIgnoreMatcher, resolveDirectorySourcemaps, } from "../../lib/sourcemap/inject.js"; diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index e36d0f557b..268a42575f 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -21,10 +21,10 @@ import { ContextError, ValidationError } from "../../lib/errors.js"; import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildIgnoreMatcher } from "../../lib/scan/index.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, - buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, type InjectResult, diff --git a/packages/cli/src/lib/formatters/wasm-prepare.ts b/packages/cli/src/lib/formatters/wasm-prepare.ts new file mode 100644 index 0000000000..287e1d91e0 --- /dev/null +++ b/packages/cli/src/lib/formatters/wasm-prepare.ts @@ -0,0 +1,93 @@ +/** + * Human-readable report for `sentry debug-files prepare`. + * + * One table per module, so a warning stays next to the module it belongs to. + * A build directory can hold dozens of modules, and a flat list of lines makes + * it easy to read a warning against the wrong path. + */ + +import type { + PrepareAction, + PrepareCommandResult, + PrepareResult, +} from "../wasm/prepare.js"; +import { + colorTag, + escapeMarkdownCell, + escapeMarkdownInline, + mdKvTable, + renderMarkdown, + safeCodeSpan, +} from "./markdown.js"; + +/** What preparation did to a module, in the past tense. */ +const ACTION_LABELS: Record = { + split: "Split", + "would-split": "Would split", + "already-prepared": "Already prepared", + skipped: colorTag("red", "Skipped"), +}; + +/** + * One module as a heading plus a key-value table. + * + * The path is the heading, in full rather than as a basename: a build tree + * routinely holds several same-named modules. + */ +function moduleSection(module: PrepareResult): string { + const rows: [string, string][] = [ + ["Action", ACTION_LABELS[module.action]], + ["Debug quality", module.quality], + ]; + if (module.buildId) { + rows.push(["Build ID", safeCodeSpan(module.buildId)]); + } + if (module.companion) { + rows.push(["Companion", safeCodeSpan(module.companion)]); + } + if (module.warning) { + rows.push([ + colorTag("yellow", "Warning"), + escapeMarkdownCell(module.warning), + ]); + } + if (module.recommendation) { + rows.push([ + colorTag("cyan", "Recommendation"), + escapeMarkdownCell(module.recommendation), + ]); + } + return mdKvTable(rows, escapeMarkdownInline(module.path)); +} + +/** + * Counts opening the report. + * + * Deliberately short: per-module facts belong in the tables below, so this only + * states how much was scanned and, when relevant, how much was uploaded. + */ +function summaryLines(data: PrepareCommandResult): string[] { + const scanned = data.modules.length; + const lines = [ + `## Found ${scanned} ${scanned === 1 ? "wasm file" : "wasm files"}`, + ]; + if (data.uploaded) { + const uploaded = data.filesUploaded; + const target = + data.org && data.project ? ` to ${data.org}/${data.project}` : ""; + lines.push( + "", + `Uploaded ${uploaded} ${uploaded === 1 ? "companion" : "companions"}${target}.` + ); + } + return lines; +} + +/** Format human-readable output for the prepare result. */ +export function formatPrepareResult(data: PrepareCommandResult): string { + const lines = summaryLines(data); + for (const module of data.modules) { + lines.push("", moduleSection(module)); + } + return renderMarkdown(lines.join("\n").trim()); +} diff --git a/packages/cli/src/lib/scan/ignore.ts b/packages/cli/src/lib/scan/ignore.ts index 420b75b6b8..581595460e 100644 --- a/packages/cli/src/lib/scan/ignore.ts +++ b/packages/cli/src/lib/scan/ignore.ts @@ -45,8 +45,50 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import ignore, { type Ignore } from "ignore"; import { handleFileError } from "../dsn/fs-utils.js"; +import { ValidationError } from "../errors.js"; import type { IgnoreMatcher } from "./types.js"; +/** + * Build an `ignore` matcher from user-supplied `--ignore` patterns and/or an + * `--ignore-file` path. Returns `undefined` when neither is set, so callers can + * skip the per-file check entirely. + * + * Patterns follow gitignore semantics (negations, anchoring, directory-only + * `dir/` entries) rather than plain glob matching, and the ignore file is + * parsed by the `ignore` package — blank lines and `#` comments are dropped. + * + * @throws {ValidationError} If `ignoreFilePath` is set but does not exist. + */ +export async function buildIgnoreMatcher( + patterns?: readonly string[], + ignoreFilePath?: string +): Promise { + const hasPatterns = patterns !== undefined && patterns.length > 0; + if (!(hasPatterns || ignoreFilePath)) { + return; + } + const ig = ignore(); + if (hasPatterns) { + ig.add([...patterns]); + } + if (ignoreFilePath) { + try { + const content = await readFile(ignoreFilePath, "utf-8"); + ig.add(content); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError( + `Ignore file '${ignoreFilePath}' does not exist.`, + "ignore-file" + ); + } + throw err; + } + } + return ig; +} + /** Options for constructing an `IgnoreStack`. */ export type IgnoreStackOptions = { /** Walker `cwd`. Absolute path. */ diff --git a/packages/cli/src/lib/scan/index.ts b/packages/cli/src/lib/scan/index.ts index 9015dcea64..5e48e543dc 100644 --- a/packages/cli/src/lib/scan/index.ts +++ b/packages/cli/src/lib/scan/index.ts @@ -52,7 +52,7 @@ export { export { collectGlob, globFiles } from "./glob.js"; export { collectGrep, grepFiles } from "./grep.js"; export type { IgnoreStackOptions } from "./ignore.js"; -export { IgnoreStack } from "./ignore.js"; +export { buildIgnoreMatcher, IgnoreStack } from "./ignore.js"; export type { CompilePatternOptions } from "./regex.js"; export { compilePattern, diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 0c51b38593..68d990594d 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -7,11 +7,11 @@ import { open, readFile, stat } from "node:fs/promises"; import { dirname, relative, resolve as resolvePath, sep } from "node:path"; -import ignore from "ignore"; +import type ignore from "ignore"; import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; -import { walkFiles } from "../scan/index.js"; +import { buildIgnoreMatcher, walkFiles } from "../scan/index.js"; import { EXISTING_DEBUGID_RE, injectDebugId, @@ -521,40 +521,6 @@ async function findCompanionMap( */ const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; -/** - * Build an `ignore` matcher from user-provided patterns and/or an - * ignore-file path. Returns `undefined` when no patterns are active. - */ -export async function buildIgnoreMatcher( - patterns?: string[], - ignoreFilePath?: string -): Promise | undefined> { - const hasPatterns = patterns && patterns.length > 0; - if (!(hasPatterns || ignoreFilePath)) { - return; - } - const ig = ignore(); - if (hasPatterns) { - ig.add(patterns); - } - if (ignoreFilePath) { - try { - const content = await readFile(ignoreFilePath, "utf-8"); - ig.add(content); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - throw new ValidationError( - `Ignore file '${ignoreFilePath}' does not exist.`, - "ignore-file" - ); - } - throw err; - } - } - return ig; -} - /** * Read-only discovery pass — returns the list of JS + sourcemap pairs * without injecting debug IDs. Used as a pre-check by the upload diff --git a/packages/cli/src/lib/wasm/binary.ts b/packages/cli/src/lib/wasm/binary.ts index 4af1f3ecd7..10599bfd37 100644 --- a/packages/cli/src/lib/wasm/binary.ts +++ b/packages/cli/src/lib/wasm/binary.ts @@ -35,6 +35,45 @@ const log = logger.withTag("wasm.binary"); /** Section id of a custom section. */ const CUSTOM_SECTION_ID = 0; +/** Section id of the type section. */ +const TYPE_SECTION_ID = 1; + +/** Section id of the import section. */ +const IMPORT_SECTION_ID = 2; + +/** Section id of the function section. */ +const FUNCTION_SECTION_ID = 3; + +/** Section id of the table section. */ +const TABLE_SECTION_ID = 4; + +/** Section id of the memory section. */ +const MEMORY_SECTION_ID = 5; + +/** Section id of the global section. */ +const GLOBAL_SECTION_ID = 6; + +/** Section id of the export section. */ +const EXPORT_SECTION_ID = 7; + +/** Section id of the start section. */ +const START_SECTION_ID = 8; + +/** Section id of the element section. */ +const ELEMENT_SECTION_ID = 9; + +/** Section id of the code section. DWARF offsets are relative to it. */ +const CODE_SECTION_ID = 10; + +/** Section id of the data section. */ +const DATA_SECTION_ID = 11; + +/** Section id of the data count section. */ +const DATA_COUNT_SECTION_ID = 12; + +/** Section id of the exception tag section, from the exception-handling proposal. */ +const EXCEPTION_TAG_SECTION_ID = 13; + /** Name of the custom section holding function names. */ const NAME_SECTION = "name"; @@ -57,19 +96,19 @@ const DEBUG_SECTION_PREFIX = ".debug_"; * 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"], + [TYPE_SECTION_ID, "type"], + [IMPORT_SECTION_ID, "import"], + [FUNCTION_SECTION_ID, "function"], + [TABLE_SECTION_ID, "table"], + [MEMORY_SECTION_ID, "memory"], + [EXCEPTION_TAG_SECTION_ID, "exception tag"], + [GLOBAL_SECTION_ID, "global"], + [EXPORT_SECTION_ID, "export"], + [START_SECTION_ID, "start"], + [ELEMENT_SECTION_ID, "element"], + [DATA_COUNT_SECTION_ID, "data count"], + [CODE_SECTION_ID, "code"], + [DATA_SECTION_ID, "data"], ] as const; /** What a non-custom section id means, and where it sorts. */ @@ -141,7 +180,7 @@ export type WasmSection = { }; /** A varuint32 read off a byte stream. */ -export type VarUint32 = { +type VarUint32 = { /** The decoded value. */ value: number; /** Bytes the encoding occupied. */ @@ -158,7 +197,7 @@ export type VarUint32 = { * @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 { +function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 { let value = 0; let scale = 1; for (let size = 0; size < MAX_VARUINT32_BYTES; size++) { @@ -192,7 +231,7 @@ export function readVarUint32(bytes: Uint8Array, offset: number): VarUint32 { * @param value - A non-negative integer below 2^32 * @returns The encoded bytes, one to five of them */ -export function writeVarUint32(value: number): Uint8Array { +function writeVarUint32(value: number): Uint8Array { const bytes: number[] = []; let remaining = value; do { @@ -321,6 +360,38 @@ export function decodeBuildId(contents: Uint8Array): Uint8Array | null { return decodeByteVector(contents, BUILD_ID_SECTION); } +/** + * Read the URL out of an `external_debug_info` section body. + * + * @param contents - The section body, after its name + * @returns The URL, or `null` when the body is malformed or not UTF-8 + */ +export function decodeExternalDebugInfo(contents: Uint8Array): string | null { + const bytes = decodeByteVector(contents, EXTERNAL_DEBUG_INFO_SECTION); + if (bytes === null) { + return null; + } + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + log.debug(`${EXTERNAL_DEBUG_INFO_SECTION} body is not valid UTF-8`, error); + return null; + } +} + +/** Whether a section is the code section. */ +export function isCodeSection(section: WasmSection): boolean { + return section.id === CODE_SECTION_ID; +} + +/** Whether a section points at a debug companion. */ +export function isExternalDebugInfoSection(section: WasmSection): boolean { + return ( + section.id === CUSTOM_SECTION_ID && + section.name === EXTERNAL_DEBUG_INFO_SECTION + ); +} + /** Whether a section is one of the custom sections carrying DWARF. */ export function isDebugSection(section: WasmSection): boolean { return ( diff --git a/packages/cli/src/lib/wasm/build-id.ts b/packages/cli/src/lib/wasm/build-id.ts index 3effc5f541..23f6d9095e 100644 --- a/packages/cli/src/lib/wasm/build-id.ts +++ b/packages/cli/src/lib/wasm/build-id.ts @@ -41,6 +41,33 @@ export function uuidToBytes(uuid: string): Uint8Array | null { } } +/** Hex digits in a UUID, excluding hyphens. */ +const UUID_HEX_LENGTH = 32; + +/** Hex-digit counts of a canonical UUID's five hyphen-separated groups. */ +const UUID_GROUP_SIZES = [8, 4, 4, 4, 12]; + +/** + * Render a hex build id as the dashed debug id Sentry indexes it under. + * + * Only the first 16 bytes are significant, mirroring how `symbolic` derives a + * WASM object's debug id from its `build_id`. A build id too short to form a + * UUID yields nothing, so callers omit the advisory id rather than send a + * malformed one. + */ +export function debugIdFromBuildId(buildId: string): string | undefined { + if (buildId.length < UUID_HEX_LENGTH) { + return; + } + const groups: string[] = []; + let offset = 0; + for (const size of UUID_GROUP_SIZES) { + groups.push(buildId.slice(offset, offset + size)); + offset += size; + } + return groups.join("-"); +} + /** * Generate a random v4 build id. * diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts new file mode 100644 index 0000000000..063a820b95 --- /dev/null +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -0,0 +1,624 @@ +/** + * Prepare WebAssembly modules for Sentry symbolication. + * + * A module compiled with DWARF carries its debug info inline, which is far too + * large to ship to users and useless to Sentry unless uploaded separately. + * Preparing a module splits it in two: a `*.debug.wasm` companion retaining + * every section, and a deployable module with the `.debug_*` sections removed. + * Both carry the same `build_id`, which is how Sentry matches a stack frame to + * its debug file. + * + * The companion must keep the Code section: DWARF addresses are relative to it, + * so a companion without it cannot be symbolicated. This mirrors the split + * performed by Symbolicator's `wasm-split`, so companions produced by either + * tool are interchangeable in Sentry. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { logger } from "../logger.js"; +import { + decodeExternalDebugInfo, + isCodeSection, + isDebugSection, + isExternalDebugInfoSection, + isNameSection, + parseSections, + type WasmSection, +} from "./binary.js"; +import { + buildIdFromSections, + formatBuildId, + randomBuildId, +} from "./build-id.js"; +import { splitWasm } from "./split.js"; + +const log = logger.withTag("wasm.prepare"); + +/** Suffix identifying a debug companion produced by this command. */ +const COMPANION_SUFFIX = ".debug.wasm"; + +/** Trailing `.wasm` extension, in any case. */ +const WASM_EXTENSION = /\.wasm$/i; + +/** How much debug information a module actually carries. */ +export type DebugQuality = + /** Inline DWARF: can be split into a companion. */ + | "dwarf" + /** Points at an external companion that was not found locally. */ + | "external-debug-info" + /** Function names only: no line-level symbolication. */ + | "symtab" + /** No debug information at all. */ + | "none"; + +/** What preparation did, or would do, to a module. */ +export type PrepareAction = + /** Module was split; a companion was written. */ + | "split" + /** Module would be split, but `--dry-run` suppressed all writes. */ + | "would-split" + /** A companion with a matching build id already exists. */ + | "already-prepared" + /** Module is not splittable; a warning explains why. */ + | "skipped"; + +/** What a module contains, as far as debug information is concerned. */ +export type WasmInspection = { + /** Classification driving the prepare decision. */ + quality: DebugQuality; + /** Existing build id, or `null` when the module has none. */ + buildId: Uint8Array | null; + /** Whether a Code section is present. */ + hasCode: boolean; + /** URL from `external_debug_info`, if the module carries one. */ + externalDebugInfo: string | null; +}; + +/** Outcome of preparing a single module. */ +export type PrepareResult = { + /** Path of the module that was inspected. */ + path: string; + /** What preparation did. */ + action: PrepareAction; + /** Debug classification of the module as found on disk. */ + quality: DebugQuality; + /** Effective build id in hex, absent only when a dry run skipped stamping. */ + buildId?: string; + /** Path of the companion, when one was written or already existed. */ + companion?: string; + /** Why the module was skipped, when it was. */ + warning?: string; + /** What to try next, when the skip looks like a build configuration issue. */ + recommendation?: string; +}; + +/** Outcome of a whole `debug-files prepare` run. */ +export type PrepareCommandResult = { + /** Organization slug. Omitted when nothing was uploaded. */ + org?: string; + /** Project slug. Omitted when nothing was uploaded. */ + project?: string; + /** Whether companions were actually uploaded. */ + uploaded: boolean; + /** Per-module outcome. */ + modules: PrepareResult[]; + /** Number of companions uploaded. */ + filesUploaded: number; +}; + +/** Options controlling preparation of one file. */ +export type PrepareOptions = { + /** Inspect and classify without writing anything. */ + dryRun?: boolean; + /** Directory for companions. Defaults to alongside the input. */ + outDir?: string; + /** Build id to inject when a module has none. Defaults to a random UUID. */ + buildId?: Uint8Array; + /** Also drop the `name` section from split deployable modules. */ + stripNames?: boolean; +}; + +/** + * Whether a quality means the module was built with DWARF. + * + * Mirrors `DebugQuality::has_dwarf`: a module pointing at an external companion + * counts, because the debug info exists somewhere even when it was not found + * locally. This is a statement about the build, not about whether Sentry can + * reach the debug info — callers that need the latter must also check that the + * companion resolved. + */ +export function hasDwarfQuality(quality: DebugQuality): boolean { + return quality === "dwarf" || quality === "external-debug-info"; +} + +/** Whether a path names a debug companion produced by this command. */ +export function isDebugCompanionPath(path: string): boolean { + return basename(path).toLowerCase().endsWith(COMPANION_SUFFIX); +} + +/** Whether a path names a WebAssembly module. */ +export function isWasmPath(path: string): boolean { + return basename(path).toLowerCase().endsWith(".wasm"); +} + +/** + * Companion path for a module: `app.wasm` becomes `app.debug.wasm`. + * + * @param wasmPath - Path of the input module. + * @param outDir - Directory to place the companion in, or omitted for + * alongside the input. + */ +export function companionPath(wasmPath: string, outDir?: string): string { + const name = basename(wasmPath).replace(WASM_EXTENSION, ""); + const fileName = `${name}${COMPANION_SUFFIX}`; + return join(outDir ?? dirname(wasmPath), fileName); +} + +/** Which debug-relevant sections a module turned out to contain. */ +type SectionSurvey = { + hasDwarf: boolean; + hasNameSection: boolean; + hasCode: boolean; + externalDebugInfo: string | null; +}; + +/** Record what one section contributes to the survey. */ +function surveySection(found: SectionSurvey, section: WasmSection): void { + if (isCodeSection(section)) { + found.hasCode = true; + } + if (!section.contents) { + return; + } + if (isNameSection(section)) { + found.hasNameSection = true; + } else if (isExternalDebugInfoSection(section)) { + found.externalDebugInfo = decodeExternalDebugInfo(section.contents); + } else if (isDebugSection(section)) { + found.hasDwarf = true; + } +} + +/** + * Reduce a survey to a single quality, best first. + * + * Inline DWARF outranks an external pointer, which outranks names alone. + */ +function classifyDebugQuality(found: SectionSurvey): DebugQuality { + if (found.hasDwarf) { + return "dwarf"; + } + if (found.externalDebugInfo) { + return "external-debug-info"; + } + return found.hasNameSection ? "symtab" : "none"; +} + +/** + * Classify a module's debug information. + * + * @param bytes - Complete module bytes. + * @returns What the module contains. + * @throws {WasmParseError} If the buffer is not a well-formed module. + */ +export function inspectWasm(bytes: Uint8Array): WasmInspection { + const found: SectionSurvey = { + hasDwarf: false, + hasNameSection: false, + hasCode: false, + externalDebugInfo: null, + }; + + const sections = parseSections(bytes); + for (const section of sections) { + surveySection(found, section); + } + + return { + quality: classifyDebugQuality(found), + buildId: buildIdFromSections(sections), + hasCode: found.hasCode, + externalDebugInfo: found.externalDebugInfo, + }; +} + +/** + * Read a module's build id off disk. + * + * @returns The id, or `null` when the file is missing, unparseable, or carries + * no readable `build_id`. Callers treat all three the same way: there is no + * id here to match against. + */ +async function readCompanionBuildId(path: string): Promise { + try { + return buildIdFromSections(parseSections(await readFile(path))); + } catch (error) { + log.debug(`No readable build id at ${path}`, error); + return null; + } +} + +/** + * Give a module a build id, writing it back in place when it has none. + * + * `wasm-split` stamps every module it processes regardless of debug quality. + * Sentry matches a stack frame to its debug file by build id, so an unstamped + * module can never be symbolicated — not even from a debug file uploaded + * later. Stamping now keeps that option open. + * + * @param path - Module to stamp. + * @param bytes - The module as read from disk. + * @param existing - Id the module already carries, if any. + * @returns The effective build id, or `null` when a dry run left the module + * untouched. + */ +async function ensureBuildIdOnDisk( + path: string, + bytes: Uint8Array, + existing: Uint8Array | null, + options: PrepareOptions +): Promise { + // Checked before splitting so an already-stamped module is never re-encoded. + if (existing) { + return existing; + } + if (options.dryRun) { + return null; + } + const stamped = stampBuildId(bytes, options.buildId); + await writeFile(path, stamped.module); + return stamped.buildId; +} + +/** + * Stamp a module with a build id, changing nothing else. + * + * A split with neither `strip` nor `companion` is exactly that, so stamping + * shares one implementation with the real split rather than reassembling the + * section list by hand. + */ +function stampBuildId(bytes: Uint8Array, buildId?: Uint8Array) { + return splitWasm(bytes, { ...(buildId ? { buildId } : {}) }); +} + +/** Whether two build ids are byte-identical. */ +function buildIdsMatch(a: Uint8Array | null, b: Uint8Array | null): boolean { + if (!(a && b) || a.length !== b.length) { + return false; + } + return a.every((byte, index) => byte === b[index]); +} + +/** Explain why a module of the given quality cannot be split. */ +function skipWarning(quality: DebugQuality): string | null { + switch (quality) { + case "dwarf": + return null; + case "external-debug-info": + return "has external_debug_info but no local companion with matching build_id"; + case "symtab": + return "no line-level symbolication (name/symtab only)"; + default: + return "no debug information; rebuild with DWARF (Emscripten -g, wasm-pack dwarf-debug-info)"; + } +} + +/** + * Suggest a next step for a skip the build can actually fix. + * + * Only missing or insufficient debug info points at the build: a module the + * compiler never emitted DWARF for, or one carrying names alone. An already + * stripped module and a dangling companion pointer are pipeline problems, so + * they get no suggestion here. + */ +function skipRecommendation(quality: DebugQuality): string | null { + if (quality === "symtab" || quality === "none") { + return "verify build flags emit DWARF"; + } + return null; +} + +/** + * Resolve an `external_debug_info` URL to a local path. + * + * Remote URLs have no local companion to check, so they resolve to nothing. + * Relative paths are taken against the module's own directory, which is how the + * companion filename written during a split is meant to be read. + */ +function resolveExternalDebugPath( + wasmPath: string, + url: string +): string | null { + if (url.startsWith("http://") || url.startsWith("https://")) { + return null; + } + return isAbsolute(url) ? url : join(dirname(wasmPath), url); +} + +/** An existing companion, and the quality to report for the pair. */ +type ExistingCompanion = { companion: string; quality: DebugQuality }; + +/** + * Detect a module that has already been prepared. + * + * A module counts as prepared when a companion carrying the same build id + * exists. Re-splitting such a module would replace a good companion with one + * holding no DWARF, so this check runs before the quality classification. + * + * The module's own `external_debug_info` pointer is followed first, so a + * companion named anything other than `.debug.wasm` (as `wasm-split` may + * produce) is still recognized. The conventional path is the fallback. + */ +async function findExistingCompanion( + wasmPath: string, + inspection: WasmInspection, + expectedCompanion: string +): Promise { + if (!inspection.buildId) { + return null; + } + + if (inspection.externalDebugInfo) { + const referenced = resolveExternalDebugPath( + wasmPath, + inspection.externalDebugInfo + ); + if ( + referenced && + buildIdsMatch(await readCompanionBuildId(referenced), inspection.buildId) + ) { + return { companion: referenced, quality: "external-debug-info" }; + } + } + + if ( + buildIdsMatch( + await readCompanionBuildId(expectedCompanion), + inspection.buildId + ) + ) { + return { companion: expectedCompanion, quality: inspection.quality }; + } + return null; +} + +/** + * Stamp a module that cannot be split, and report why it was left alone. + * + * @returns The skip, carrying the build id the module ends up with unless a + * dry run left it unstamped, or `null` when the module is splittable. + */ +async function reportSkip( + path: string, + bytes: Uint8Array, + inspection: WasmInspection, + options: PrepareOptions +): Promise { + const warning = skipWarning(inspection.quality); + if (!warning) { + return null; + } + const buildId = await ensureBuildIdOnDisk( + path, + bytes, + inspection.buildId, + options + ); + const recommendation = skipRecommendation(inspection.quality); + return { + path, + action: "skipped", + quality: inspection.quality, + ...(buildId ? { buildId: formatBuildId(buildId) } : {}), + warning, + ...(recommendation ? { recommendation } : {}), + }; +} + +/** + * Give a module and the companion it names a shared build id. + * + * A module carrying `external_debug_info` but no build id was split by a tool + * that never stamped it. The pair is intact apart from the id Sentry matches + * on, so it is repairable — and stamping the module alone would make it + * unrepairable, since a random id can never be reconciled with the companion. + * + * The companion is written first. A crash between the two writes then leaves + * the module unstamped, which the next run repairs; the reverse order would + * leave a stamped module pointing at an unstamped companion, which reads as a + * dangling pointer forever after. + * + * @returns The repaired pair, or `null` when there is nothing to repair and + * the caller should fall through to the normal skip path. + */ +async function repairUnpairedCompanion( + wasmPath: string, + bytes: Uint8Array, + inspection: WasmInspection, + options: PrepareOptions +): Promise { + if (!inspection.externalDebugInfo) { + return null; + } + const companion = resolveExternalDebugPath( + wasmPath, + inspection.externalDebugInfo + ); + // A remote URL names no file this run can stamp. + if (!companion) { + return null; + } + + let companionBytes: Uint8Array; + let companionInspection: WasmInspection; + try { + companionBytes = await readFile(companion); + companionInspection = inspectWasm(companionBytes); + } catch (error) { + log.debug(`No usable companion at ${companion}`, error); + return null; + } + + // Without DWARF the companion is not a debug file, so pairing with it would + // upload nothing useful under an id the module now claims. + if (companionInspection.quality !== "dwarf") { + return null; + } + + const adopted = companionInspection.buildId; + const buildId = adopted ?? options.buildId ?? randomBuildId(); + + if (options.dryRun) { + return { + path: wasmPath, + action: "already-prepared", + quality: "external-debug-info", + // A generated id does not exist yet; only report one already on disk. + ...(adopted ? { buildId: formatBuildId(adopted) } : {}), + companion, + warning: "would reconcile build_id with companion", + }; + } + + if (!adopted) { + await writeFile(companion, stampBuildId(companionBytes, buildId).module); + } + await writeFile(wasmPath, stampBuildId(bytes, buildId).module); + + return { + path: wasmPath, + action: "already-prepared", + quality: "external-debug-info", + buildId: formatBuildId(buildId), + companion, + warning: adopted + ? "reconciled build_id with companion" + : "reconciled build_id with companion (stamped both files)", + }; +} + +/** + * Classify and, where possible, split a single `.wasm` file. + * + * Modules that cannot be split are stamped with a build id and reported with a + * warning rather than failing the run, so a scan over a build directory does + * not stop at the first module lacking DWARF. + * + * @param path - Path of the module to prepare. + * @param options - Dry-run, output directory, and stripping behaviour. + * @returns What was done, including the companion path when one was produced. + */ +export async function prepareWasmFile( + path: string, + options: PrepareOptions = {} +): Promise { + if (isDebugCompanionPath(path)) { + return { + path, + action: "skipped", + quality: "dwarf", + warning: "already a debug companion (*.debug.wasm); skipping", + }; + } + + const bytes = await readFile(path); + let inspection: WasmInspection; + try { + inspection = inspectWasm(bytes); + } catch (error) { + return { + path, + action: "skipped", + quality: "none", + warning: `not a valid WASM module: ${(error as Error).message}`, + }; + } + + const expectedCompanion = companionPath(path, options.outDir); + + if (inspection.quality !== "dwarf") { + const existing = await findExistingCompanion( + path, + inspection, + expectedCompanion + ); + if (existing) { + return { + path, + action: "already-prepared", + quality: existing.quality, + buildId: formatBuildId(inspection.buildId as Uint8Array), + companion: existing.companion, + }; + } + } + + // Repair before skipping: an unstamped module with a companion is a pair a + // previous tool left half-finished, not a module that cannot be prepared. + if (!inspection.buildId && inspection.quality === "external-debug-info") { + const repaired = await repairUnpairedCompanion( + path, + bytes, + inspection, + options + ); + if (repaired) { + return repaired; + } + } + + const skipped = await reportSkip(path, bytes, inspection, options); + if (skipped) { + return skipped; + } + + if (options.dryRun) { + return { + path, + action: "would-split", + quality: inspection.quality, + ...(inspection.buildId + ? { buildId: formatBuildId(inspection.buildId) } + : {}), + companion: expectedCompanion, + }; + } + + const split = splitWasm(bytes, { + companion: true, + strip: true, + ...(options.buildId ? { buildId: options.buildId } : {}), + stripNames: options.stripNames ?? false, + externalDebugInfo: basename(expectedCompanion), + }); + + // Write the companion first: if the process dies between the two writes, an + // orphan companion is recoverable, whereas a stripped module whose DWARF was + // never saved anywhere is not. + await writeFile(expectedCompanion, split.companion as Uint8Array); + await writeFile(path, split.module); + + return { + path, + action: "split", + quality: inspection.quality, + buildId: formatBuildId(split.buildId), + companion: expectedCompanion, + }; +} + +/** + * Path to upload to Sentry for a result, if it produced one. + * + * Only split modules yield a debug file. A name/symtab-only module keeps its + * `name` section in the deployable copy and runtimes resolve function names + * from it directly, so uploading it would add nothing the stack trace does not + * already carry. + */ +export function uploadPath(result: PrepareResult): string | null { + if (result.action === "split" || result.action === "already-prepared") { + return result.companion ?? null; + } + return null; +} diff --git a/packages/cli/test/commands/debug-files/prepare.test.ts b/packages/cli/test/commands/debug-files/prepare.test.ts new file mode 100644 index 0000000000..c436b2c52a --- /dev/null +++ b/packages/cli/test/commands/debug-files/prepare.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for `debug-files prepare` human output and the `--require-dwarf` gate. + * + * Output assertions cover the rendered string only; split and stamp behaviour is + * covered by test/lib/wasm/prepare.test.ts. + * + * Vitest runs without a TTY, so `isPlainOutput()` is true and color tags are + * stripped. That makes the expected strings stable and also proves plain mode + * emits no stray tag markup. + */ + +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import chalk from "chalk"; +import { describe, expect, test, vi } from "vitest"; +import { + lacksDwarf, + prepareCommand, +} from "../../../src/commands/debug-files/prepare.js"; +import { ValidationError } from "../../../src/lib/errors.js"; +import { COLORS } from "../../../src/lib/formatters/colors.js"; +import { formatPrepareResult } from "../../../src/lib/formatters/wasm-prepare.js"; +import type { PrepareResult } from "../../../src/lib/wasm/prepare.js"; + +/** A split module with every optional field populated. */ +const splitModule: PrepareResult = { + path: "web/assets/maze.wasm", + action: "split", + quality: "dwarf", + buildId: "d5bcf04a000040008000000000000000", + companion: "web/assets/maze.debug.wasm", +}; + +/** A skipped module carrying a warning. */ +const skippedModule: PrepareResult = { + path: "web/assets/maze.nosym.wasm", + action: "skipped", + quality: "none", + buildId: "00000000000040008000000000000000", + warning: "already stripped (build_id present, no debug sections)", +}; + +/** Render a report that uploaded nothing, for the given modules. */ +function report(...modules: PrepareResult[]): string { + return formatPrepareResult({ + uploaded: false, + modules, + filesUploaded: 0, + }); +} + +describe("formatPrepareResult", () => { + test("gives each module its own table, keyed by full path", () => { + const output = report({ + ...splitModule, + path: "/build/out/nested/app.wasm", + companion: "/symbols/app.debug.wasm", + }); + + expect(output).toContain("/build/out/nested/app.wasm"); + expect(output).toContain("Action"); + expect(output).toContain("Split"); + expect(output).toContain("Debug quality"); + expect(output).toContain("dwarf"); + expect(output).toContain("d5bcf04a000040008000000000000000"); + expect(output).toContain("/symbols/app.debug.wasm"); + }); + + test("attaches a warning and a recommendation to their own module", () => { + const output = report({ + path: "app.wasm", + action: "skipped", + quality: "symtab", + warning: "no line-level symbolication (name/symtab only)", + recommendation: "verify build flags emit DWARF", + }); + + expect(output).toContain("Skipped"); + expect(output).toContain("Warning"); + expect(output).toContain("no line-level symbolication (name/symtab only)"); + expect(output).toContain("Recommendation"); + expect(output).toContain("verify build flags emit DWARF"); + }); + + test("labels each action", () => { + const label = (action: PrepareResult["action"]) => + report({ ...splitModule, action }); + + expect(label("split")).toContain("Split"); + expect(label("would-split")).toContain("Would split"); + expect(label("already-prepared")).toContain("Already prepared"); + expect(label("skipped")).toContain("Skipped"); + }); + + test("prints quality as the JSON payload spells it", () => { + const output = report({ ...splitModule, quality: "external-debug-info" }); + + expect(output).toContain("external-debug-info"); + }); + + test("omits Build ID and Companion when absent", () => { + const output = report({ + path: "app.wasm", + action: "skipped", + quality: "symtab", + }); + + expect(output).not.toContain("Build ID"); + expect(output).not.toContain("Companion"); + }); + + test("does not let markdown in a path alter the output", () => { + // Underscores are emphasis markers in markdown; a real filename keeps them. + const output = report({ ...splitModule, path: "build/_my_module_.wasm" }); + + expect(output).toContain("build/_my_module_.wasm"); + }); + + test("opens with the scanned count and pluralizes it", () => { + expect(report(splitModule).split("\n")[0]).toBe("Found 1 wasm file"); + expect(report(splitModule, skippedModule).split("\n")[0]).toBe( + "Found 2 wasm files" + ); + }); + + test("reports the upload target only after an upload", () => { + const uploaded = formatPrepareResult({ + org: "my-org", + project: "my-project", + uploaded: true, + modules: [splitModule], + filesUploaded: 1, + }); + + expect(uploaded).toContain("Uploaded 1 companion to my-org/my-project"); + expect(report(splitModule)).not.toContain("Uploaded"); + }); + + test("renders a summary alone when no modules were found", () => { + expect(report()).toContain("Found 0 wasm files"); + }); + + test("colors the skip label red in terminal output", () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const level = chalk.level; + chalk.level = 3; + try { + expect(report(skippedModule)).toContain(chalk.hex(COLORS.red)("Skipped")); + } finally { + chalk.level = level; + delete process.env.SENTRY_PLAIN_OUTPUT; + } + }); + + test("leaves no color tag markup in plain mode", () => { + const output = formatPrepareResult({ + org: "my-org", + project: "my-project", + uploaded: true, + modules: [splitModule, skippedModule], + filesUploaded: 1, + }); + + expect(output).not.toMatch(/<\/?(muted|yellow|red|cyan)>/); + }); +}); + +describe("lacksDwarf", () => { + const skipped = (quality: PrepareResult["quality"]): PrepareResult => ({ + path: "app.wasm", + action: "skipped", + quality, + }); + + test("fails a dangling external_debug_info pointer", () => { + // The companion could not be resolved — a resolved one would have been + // reported as already-prepared — so nothing reachable was uploaded. + expect(lacksDwarf(skipped("external-debug-info"))).toBe(true); + }); + + test("fails a module with no usable debug info", () => { + expect(lacksDwarf(skipped("symtab"))).toBe(true); + expect(lacksDwarf(skipped("none"))).toBe(true); + }); + + test("passes a companion named directly on the command line", () => { + expect(lacksDwarf(skipped("dwarf"))).toBe(false); + }); + + test("passes every module that produced a debug file", () => { + expect(lacksDwarf(splitModule)).toBe(false); + expect(lacksDwarf({ ...splitModule, action: "already-prepared" })).toBe( + false + ); + expect(lacksDwarf({ ...splitModule, action: "would-split" })).toBe(false); + }); +}); + +describe("--build-id", () => { + const BUILD_ID = "a1b2c3d4-e5f6-4788-99aa-bbccddeeff00"; + + /** Run the command with `--build-id` over the given paths. */ + async function runWithBuildId(...paths: string[]): Promise { + const func = (await prepareCommand.loader()) as unknown as ( + this: unknown, + flags: Record, + ...rest: string[] + ) => Promise; + const context = { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + env: {} as NodeJS.ProcessEnv, + process: { ...process, exitCode: undefined } as typeof process, + }; + await func.call( + context, + { "build-id": BUILD_ID, "dry-run": true, "no-upload": true }, + ...paths + ); + } + + /** A directory holding one module, so only the path kind is under test. */ + async function moduleDir(): Promise<{ dir: string; module: string }> { + const dir = await mkdtemp(join(tmpdir(), "prepare-build-id-")); + const module = join(dir, "app.wasm"); + await writeFile(module, Uint8Array.from([0x00, 0x61, 0x73, 0x6d])); + return { dir, module }; + } + + test("is refused for a directory, which may hold several modules", async () => { + const { dir } = await moduleDir(); + + await expect(runWithBuildId(dir)).rejects.toThrow(ValidationError); + }); + + test("is refused for several paths", async () => { + const { module } = await moduleDir(); + const second = await moduleDir(); + + await expect(runWithBuildId(module, second.module)).rejects.toThrow( + /single .wasm file/ + ); + }); + + test("is accepted for one named module", async () => { + const { module } = await moduleDir(); + + await expect(runWithBuildId(module)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/test/lib/wasm/binary.test.ts b/packages/cli/test/lib/wasm/binary.test.ts index ce9dc67184..74ac5b6443 100644 --- a/packages/cli/test/lib/wasm/binary.test.ts +++ b/packages/cli/test/lib/wasm/binary.test.ts @@ -16,9 +16,7 @@ import { makeBuildIdSection, makeExternalDebugInfoSection, parseSections, - readVarUint32, WasmParseError, - writeVarUint32, } from "../../../src/lib/wasm/binary.js"; import { byteVector, @@ -33,34 +31,6 @@ import { 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")], diff --git a/packages/cli/test/lib/wasm/prepare.test.ts b/packages/cli/test/lib/wasm/prepare.test.ts new file mode 100644 index 0000000000..19ec77dcef --- /dev/null +++ b/packages/cli/test/lib/wasm/prepare.test.ts @@ -0,0 +1,426 @@ +/** + * Behavioural tests for WASM debug-file preparation. + * + * These cover the prepare decisions — what gets split, stamped, skipped, or + * reconciled — and the on-disk effect of each. The split primitive itself is + * covered by split.test.ts, and the envelope parser by binary.test.ts. + */ + +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { parseSections } from "../../../src/lib/wasm/binary.js"; +import { formatBuildId, uuidToBytes } from "../../../src/lib/wasm/build-id.js"; +import { + companionPath, + hasDwarfQuality, + inspectWasm, + prepareWasmFile, + uploadPath, +} from "../../../src/lib/wasm/prepare.js"; +import { splitWasm } from "../../../src/lib/wasm/split.js"; +import { + byteVector, + CODE_SECTION_ID, + customSection, + fromHex, + section, + toHex, + wasmModule, +} from "./helpers.js"; + +/** Deterministic build id used where the exact value matters. */ +const FIXED_UUID = "00000000-0000-4000-8000-000000000000"; + +/** A stand-in code section. Its contents are opaque to the parser. */ +function code(): Uint8Array { + return section(CODE_SECTION_ID, fromHex("00")); +} + +/** A DWARF debug section. */ +function dwarf(): Uint8Array { + return customSection(".debug_info", fromHex("0102")); +} + +/** A `build_id` section holding the given bytes. */ +function buildIdSection(buildId: Uint8Array): Uint8Array { + return customSection("build_id", byteVector(buildId)); +} + +/** An `external_debug_info` section pointing at `url`. */ +function externalDebugInfoSection(url: string): Uint8Array { + return customSection( + "external_debug_info", + byteVector(new TextEncoder().encode(url)) + ); +} + +/** Module with inline DWARF: the splittable case. */ +function dwarfModule(): Uint8Array { + return wasmModule([code(), dwarf()]); +} + +/** Module with function names only: no line-level debug info. */ +function nameOnlyModule(): Uint8Array { + return wasmModule([code(), customSection("name", fromHex("00"))]); +} + +/** Module with no debug information at all. */ +function emptyModule(): Uint8Array { + return wasmModule([code()]); +} + +/** Module already stripped: a build id but no debug sections. */ +function strippedModule(): Uint8Array { + return wasmModule([code(), buildIdSection(new Uint8Array(16).fill(0x07))]); +} + +/** Whether a module carries a custom section with the given name. */ +function hasSection(bytes: Uint8Array, name: string): boolean { + return parseSections(bytes).some((entry) => entry.name === name); +} + +/** Split a module the way `prepareWasmFile` does, for fixture setup. */ +function split(bytes: Uint8Array, companionName: string) { + return splitWasm(bytes, { + companion: true, + strip: true, + buildId: uuidToBytes(FIXED_UUID) as Uint8Array, + externalDebugInfo: companionName, + }); +} + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "wasm-prepare-")); +}); + +afterEach(() => { + dir = ""; +}); + +/** Write a module into the temp dir and return its path. */ +async function writeModule(name: string, bytes: Uint8Array): Promise { + const path = join(dir, name); + await writeFile(path, bytes); + return path; +} + +describe("hasDwarfQuality", () => { + test("counts an external companion pointer as having DWARF", () => { + // Mirrors DebugQuality::has_dwarf in the Rust CLI: this describes how the + // module was built, not whether its debug info can be reached. The + // --require-dwarf gate applies the reachability check on top. + expect(hasDwarfQuality("dwarf")).toBe(true); + expect(hasDwarfQuality("external-debug-info")).toBe(true); + expect(hasDwarfQuality("symtab")).toBe(false); + expect(hasDwarfQuality("none")).toBe(false); + }); +}); + +/** + * Golden vectors captured from Symbolicator's `wasm-split` (via `wasmbin`), + * splitting a module holding a single `.debug_info` section with the build id + * pinned to {@link FIXED_UUID}. + * + * Sentry treats companions from either tool identically, so these bytes are a + * compatibility contract, not an implementation detail. Decoded, the outputs + * confirm the two custom-section encodings this module depends on: `build_id` + * is a length-prefixed byte vector, `external_debug_info` a length-prefixed + * UTF-8 string. + */ +const WASM_SPLIT_INPUT = "0061736d0100000000100b2e64656275675f696e666f00010203"; +const WASM_SPLIT_STRIPPED = + "0061736d01000000001a086275696c645f6964100000000000004000800000000000000000231365787465726e616c5f64656275675f696e666f0e6170702e64656275672e7761736d"; +const WASM_SPLIT_COMPANION = + "0061736d0100000000100b2e64656275675f696e666f00010203001a086275696c645f69641000000000000040008000000000000000"; + +describe("wasm-split compatibility", () => { + test("produces byte-identical output to wasm-split", () => { + const result = split(fromHex(WASM_SPLIT_INPUT), "app.debug.wasm"); + + expect(toHex(result.module)).toBe(WASM_SPLIT_STRIPPED); + expect(toHex(result.companion as Uint8Array)).toBe(WASM_SPLIT_COMPANION); + }); + + test("reads back a module wasm-split wrote", () => { + const inspection = inspectWasm(fromHex(WASM_SPLIT_STRIPPED)); + + expect(inspection.quality).toBe("external-debug-info"); + expect(inspection.externalDebugInfo).toBe("app.debug.wasm"); + expect(toHex(inspection.buildId as Uint8Array)).toBe( + "00000000000040008000000000000000" + ); + }); +}); + +describe("prepareWasmFile", () => { + test("splits a module with DWARF", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("split"); + expect(result.quality).toBe("dwarf"); + expect(existsSync(companionPath(path))).toBe(true); + expect(uploadPath(result)).toBe(companionPath(path)); + // The deployable keeps its original path so the build artifact does not move. + expect(hasSection(await readFile(path), ".debug_info")).toBe(false); + }); + + test("stamps a symtab-only module but uploads nothing", async () => { + const path = await writeModule("unity.wasm", nameOnlyModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("symtab"); + expect(result.warning).toContain("no line-level"); + expect(existsSync(companionPath(path))).toBe(false); + // Stamped so a later DWARF build can be matched, but the name section stays + // readable in the deployable, so there is nothing worth uploading. + expect(result.buildId).toBeDefined(); + expect(uploadPath(result)).toBeNull(); + expect(inspectWasm(await readFile(path)).buildId).not.toBeNull(); + }); + + test("stamps a module with no debug info", async () => { + const path = await writeModule("app.wasm", emptyModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("none"); + expect(result.warning).toContain("no debug information"); + expect(result.buildId).toBeDefined(); + }); + + test("does not re-split a module that only has a build id", async () => { + const path = await writeModule("app.wasm", strippedModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("none"); + expect(result.warning).toContain("no debug information"); + expect(existsSync(companionPath(path))).toBe(false); + }); + + test("recommends checking build flags when debug info is missing", async () => { + const missing = await prepareWasmFile( + await writeModule("empty.wasm", emptyModule()) + ); + const names = await prepareWasmFile( + await writeModule("names.wasm", nameOnlyModule()) + ); + + expect(missing.recommendation).toBe("verify build flags emit DWARF"); + expect(names.recommendation).toBe("verify build flags emit DWARF"); + }); + + test("detects an already-prepared pair on a second run", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const first = await prepareWasmFile(path); + const second = await prepareWasmFile(path); + + expect(first.action).toBe("split"); + expect(second.action).toBe("already-prepared"); + expect(second.buildId).toBe(first.buildId); + }); + + test("dry run writes nothing and stamps nothing", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const before = await readFile(path); + const result = await prepareWasmFile(path, { dryRun: true }); + + expect(result.action).toBe("would-split"); + expect(existsSync(companionPath(path))).toBe(false); + expect(await readFile(path)).toEqual(before); + }); + + test("dry run does not stamp a skipped module", async () => { + const path = await writeModule("unity.wasm", nameOnlyModule()); + const result = await prepareWasmFile(path, { dryRun: true }); + + expect(result.action).toBe("skipped"); + expect(result.buildId).toBeUndefined(); + expect(inspectWasm(await readFile(path)).buildId).toBeNull(); + }); + + test("uses an explicit build id", async () => { + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { + buildId: uuidToBytes(FIXED_UUID) as Uint8Array, + }); + + expect(result.buildId).toBe("00000000000040008000000000000000"); + }); + + test("out-dir redirects the companion but strips in place", async () => { + const outDir = await mkdtemp(join(tmpdir(), "wasm-out-")); + const path = await writeModule("app.wasm", dwarfModule()); + const result = await prepareWasmFile(path, { outDir }); + + expect(result.companion).toBe(join(outDir, "app.debug.wasm")); + expect(existsSync(join(outDir, "app.debug.wasm"))).toBe(true); + expect(existsSync(companionPath(path))).toBe(false); + // The deployed path is the one that ends up stripped and stamped. + expect(hasSection(await readFile(path), ".debug_info")).toBe(false); + expect(inspectWasm(await readFile(path)).buildId).not.toBeNull(); + }); + + test("skips a companion given as input", async () => { + const path = await writeModule("app.debug.wasm", dwarfModule()); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.warning).toContain("already a debug companion"); + }); + + test("recognizes a companion named by external_debug_info", async () => { + // A companion whose name does not follow the .debug.wasm convention + // is only findable by following the module's own pointer. + const result = split(dwarfModule(), "custom-name.wasm"); + const path = await writeModule("app.wasm", result.module); + await writeModule("custom-name.wasm", result.companion as Uint8Array); + + const prepared = await prepareWasmFile(path); + + expect(prepared.action).toBe("already-prepared"); + expect(prepared.quality).toBe("external-debug-info"); + expect(prepared.companion).toBe(join(dir, "custom-name.wasm")); + }); + + test("ignores a remote external_debug_info URL", async () => { + const result = split(dwarfModule(), "https://example.com/app.debug.wasm"); + const path = await writeModule("app.wasm", result.module); + + const prepared = await prepareWasmFile(path); + + // Nothing local to verify, so it is reported rather than claimed prepared. + expect(prepared.action).toBe("skipped"); + expect(prepared.quality).toBe("external-debug-info"); + }); + + test("skips a file that is not a WASM module", async () => { + const path = join(dir, "not-wasm.wasm"); + await writeFile(path, "definitely not wasm"); + const result = await prepareWasmFile(path); + + expect(result.action).toBe("skipped"); + expect(result.warning).toContain("not a valid WASM module"); + }); +}); + +describe("prepareWasmFile: pairs split by another tool", () => { + /** Deployable left by a tool that stripped DWARF without stamping a build id. */ + function unstampedModule(companionName: string): Uint8Array { + return wasmModule([code(), externalDebugInfoSection(companionName)]); + } + + /** Companion carrying DWARF, optionally already stamped. */ + function companionModule(buildId?: Uint8Array): Uint8Array { + const sections = [code(), dwarf()]; + if (buildId) { + sections.push(buildIdSection(buildId)); + } + return wasmModule(sections); + } + + /** Write an unstamped module and its companion; return both paths. */ + async function writePair( + companionBuildId?: Uint8Array + ): Promise<{ module: string; companion: string }> { + return { + module: await writeModule("app.wasm", unstampedModule("app.dbg.wasm")), + companion: await writeModule( + "app.dbg.wasm", + companionModule(companionBuildId) + ), + }; + } + + test("adopts the build id the companion already carries", async () => { + const expected = uuidToBytes(FIXED_UUID) as Uint8Array; + const { module, companion } = await writePair(expected); + const before = await readFile(companion); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("already-prepared"); + expect(result.buildId).toBe(formatBuildId(expected)); + expect(result.companion).toBe(companion); + expect(result.warning).toBe("reconciled build_id with companion"); + // The module gains the companion's id; the companion is left untouched. + const stamped = inspectWasm(await readFile(module)).buildId as Uint8Array; + expect(Uint8Array.from(stamped)).toEqual(expected); + expect(await readFile(companion)).toEqual(before); + }); + + test("stamps one shared id when neither file has one", async () => { + const { module, companion } = await writePair(); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("already-prepared"); + expect(result.warning).toContain("stamped both files"); + + const moduleId = inspectWasm(await readFile(module)).buildId; + const companionId = inspectWasm(await readFile(companion)).buildId; + expect(moduleId).not.toBeNull(); + expect(companionId).toEqual(moduleId); + expect(result.buildId).toBe(formatBuildId(moduleId as Uint8Array)); + }); + + test("uploads the repaired companion", async () => { + const { module, companion } = await writePair(); + + expect(uploadPath(await prepareWasmFile(module))).toBe(companion); + }); + + test("writes nothing on a dry run", async () => { + const { module, companion } = await writePair(); + const moduleBefore = await readFile(module); + const companionBefore = await readFile(companion); + + const result = await prepareWasmFile(module, { dryRun: true }); + + expect(result.warning).toBe("would reconcile build_id with companion"); + expect(await readFile(module)).toEqual(moduleBefore); + expect(await readFile(companion)).toEqual(companionBefore); + }); + + test("splits rather than reconciles when DWARF is still inline", async () => { + // A pointer left by a tool that never stripped the module: the DWARF is + // right here, so producing a real companion beats adopting a foreign id. + const module = await writeModule( + "app.wasm", + wasmModule([code(), dwarf(), externalDebugInfoSection("app.dbg.wasm")]) + ); + await writeModule("app.dbg.wasm", companionModule()); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("split"); + expect(hasSection(await readFile(module), ".debug_info")).toBe(false); + }); + + test("skips when the companion is missing", async () => { + const module = await writeModule("app.wasm", unstampedModule("gone.wasm")); + + const result = await prepareWasmFile(module); + + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("external-debug-info"); + }); + + test("skips when the companion carries no DWARF", async () => { + const module = await writeModule("app.wasm", unstampedModule("bare.wasm")); + await writeModule("bare.wasm", emptyModule()); + + const result = await prepareWasmFile(module); + + // Pairing with it would claim an id for a file holding no debug info. + expect(result.action).toBe("skipped"); + expect(result.quality).toBe("external-debug-info"); + }); +});