diff --git a/docs/src/fragments/commands/debug-files.md b/docs/src/fragments/commands/debug-files.md index 28f15855f..f9a365c5d 100644 --- a/docs/src/fragments/commands/debug-files.md +++ b/docs/src/fragments/commands/debug-files.md @@ -38,6 +38,10 @@ sentry debug-files upload ./build --no-zips sentry debug-files upload ./dsyms --type dsym --wait sentry debug-files upload ./build --id --require-all +# Unity: upload IL2CPP line mappings (optionally with referenced C# sources) +sentry debug-files upload ./build --il2cpp-mapping +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 ``` @@ -72,8 +76,14 @@ sentry debug-files upload ./build --no-upload server-side processing and exit non-zero if any file fails. `--require-all` fails if a requested `--id` was not found. The server-advertised maximum file size and maximum processing wait are honored automatically (oversized files - are skipped with a warning). `--symbol-maps` (BCSymbolMap resolution) and - `--il2cpp-mapping` line mappings are not yet supported. + are skipped with a warning). `--symbol-maps` (BCSymbolMap resolution) is not + yet supported. +- Managed .NET PE assemblies that embed a Portable PDB have it extracted and + uploaded automatically as a separate `.pdb` debug file (no flag needed). +- `--il2cpp-mapping` computes Unity IL2CPP C++→C# line mappings from each file's + referenced generated C++ sources and uploads them as separate `il2cpp` debug + files. Combine with `--include-sources` to also bundle the referenced C# + source files. - Upload a JVM bundle separately via `sentry debug-files upload --type jvm`. - Supported JVM source file extensions: `.java`, `.kt`, `.scala`, `.sc`, `.groovy`, `.gvy`, `.gy`, `.gsh`, `.clj`, `.cljc` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 8c67fa320..ed5f083ab 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -27,6 +27,7 @@ Upload debug information files to Sentry - `--no-unwind - Do not upload files whose only feature is unwind info` - `--no-sources - Do not upload files whose only feature is source info` - `--include-sources - Build and upload a source bundle for each file with debug info` +- `--il2cpp-mapping - Compute and upload Unity IL2CPP line mappings for each scanned file` - `--derived-data - Also scan Xcode's DerivedData folder (macOS only)` - `--no-zips - Do not scan inside .zip archives` - `--no-upload - Scan and print what would be uploaded without uploading` @@ -90,6 +91,10 @@ sentry debug-files upload ./build --no-zips sentry debug-files upload ./dsyms --type dsym --wait sentry debug-files upload ./build --id --require-all +# Unity: upload IL2CPP line mappings (optionally with referenced C# sources) +sentry debug-files upload ./build --il2cpp-mapping +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 ``` diff --git a/src/commands/debug-files/upload.ts b/src/commands/debug-files/upload.ts index 2f0c3d86a..cf7cd63cc 100644 --- a/src/commands/debug-files/upload.ts +++ b/src/commands/debug-files/upload.ts @@ -12,9 +12,10 @@ * and `max_wait` (clamps the processing wait). `.zip` archives are scanned in * place (disable with `--no-zips`); `--derived-data` additionally scans Xcode's * DerivedData folder on macOS. Managed PE assemblies that embed a Portable PDB - * have it extracted and uploaded automatically as a separate DIF. `--symbol-maps` - * (BCSymbolMap resolution) and `--il2cpp-mapping` line mappings are deferred to - * follow-up PRs (see the command's full description). + * have it extracted and uploaded automatically as a separate DIF, and + * `--il2cpp-mapping` uploads Unity IL2CPP line mappings. `--symbol-maps` + * (BCSymbolMap resolution) is deferred to a follow-up PR (see the command's + * full description). */ import { createHash } from "node:crypto"; @@ -34,7 +35,10 @@ import { uploadDebugFiles, } from "../../lib/api/debug-files.js"; import { buildCommand } from "../../lib/command.js"; -import { createSourceBundle } from "../../lib/dif/index.js"; +import { + createIl2cppLineMapping, + createSourceBundle, +} from "../../lib/dif/index.js"; import { buildDifFilters, debugIdMatches, @@ -127,6 +131,7 @@ type UploadFlags = { "no-unwind"?: boolean; "no-sources"?: boolean; "include-sources"?: boolean; + "il2cpp-mapping"?: boolean; "derived-data"?: boolean; "no-zips"?: boolean; "no-upload"?: boolean; @@ -171,8 +176,54 @@ function difKey(dif: DebugFileUpload): string { } /** - * Convert prepared files into the DIF upload list, optionally appending a - * source bundle per file when `--include-sources` is set. + * 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. + * + * The generated C++ source files the object references are read from disk; + * files not present locally are skipped. Failures (or a file with no IL2CPP + * data) are swallowed (logged at debug level) so they never abort the upload. + */ +function appendIl2cppMapping(difs: DebugFileUpload[], file: PreparedDif): void { + let result: ReturnType; + try { + result = createIl2cppLineMapping( + new Uint8Array(file.content), + readSourceFile + ); + } catch (err) { + log.debug(`Could not compute IL2CPP line mapping for ${file.path}`, err); + return; + } + if (!result) { + return; + } + difs.push({ + name: `${basename(file.path)}.il2cpp`, + debugId: file.debugId ?? result.debugId, + content: Buffer.from(result.mapping), + }); +} + +/** + * Convert prepared files into the DIF upload list. + * + * For each prepared file this queues the main DIF, then optionally a Unity + * IL2CPP line-mapping DIF (`--il2cpp-mapping`) and a per-file source bundle + * (`--include-sources`). Combining both also bundles the C# sources referenced + * by IL2CPP `source_info` markers (`collectIl2cppSources`). * * Embedded Portable PDBs (extracted from managed PE assemblies during scanning) * arrive here as ordinary prepared DIFs, so no special handling is needed. @@ -183,8 +234,9 @@ function difKey(dif: DebugFileUpload): string { */ function buildDifList( prepared: PreparedDif[], - includeSources: boolean + options: { includeSources: boolean; il2cppMapping: boolean } ): DebugFileUpload[] { + const { includeSources, il2cppMapping } = options; const difs: DebugFileUpload[] = []; for (const file of prepared) { difs.push({ @@ -193,6 +245,10 @@ function buildDifList( content: file.content, }); + if (il2cppMapping) { + appendIl2cppMapping(difs, file); + } + if (!includeSources) { continue; } @@ -202,17 +258,8 @@ function buildDifList( result = createSourceBundle( new Uint8Array(file.content), basename(file.path), - (sourcePath) => { - try { - return readFileSync(sourcePath); - } catch (err) { - log.debug( - `Source file not available, skipping: ${sourcePath}`, - err - ); - return null; - } - } + readSourceFile, + { collectIl2cppSources: il2cppMapping } ); } catch (err) { log.debug(`Could not build source bundle for ${file.path}`, err); @@ -460,15 +507,19 @@ export const uploadCommand = buildCommand({ "recursed.\n\n" + "Managed PE assemblies (.NET) that embed a Portable PDB have it extracted " + "and uploaded automatically as a separate .pdb debug file.\n\n" + + "With --il2cpp-mapping, Unity IL2CPP C++->C# line mappings are computed " + + "from each file's referenced generated C++ sources and uploaded as " + + "separate il2cpp debug files; combine with --include-sources to also " + + "bundle the referenced C# source files.\n\n" + "Usage:\n" + " sentry debug-files upload ./build\n" + " sentry debug-files upload ./symbols.zip\n" + " sentry debug-files upload ./libexample.so --include-sources\n" + " sentry debug-files upload ./dsyms --type dsym --wait\n" + + " sentry debug-files upload ./build --il2cpp-mapping --include-sources\n" + " sentry debug-files upload --derived-data --no-upload\n" + " sentry debug-files upload ./build --no-zips --no-upload\n\n" + - "Not yet supported (planned): --symbol-maps (BCSymbolMap resolution) " + - "and --il2cpp-mapping line mappings.", + "Not yet supported (planned): --symbol-maps (BCSymbolMap resolution).", }, output: { human: formatUploadResult, @@ -529,6 +580,13 @@ export const uploadCommand = buildCommand({ optional: true, default: false, }, + "il2cpp-mapping": { + kind: "boolean", + brief: + "Compute and upload Unity IL2CPP line mappings for each scanned file", + optional: true, + default: false, + }, "derived-data": { kind: "boolean", brief: "Also scan Xcode's DerivedData folder (macOS only)", @@ -610,7 +668,10 @@ export const uploadCommand = buildCommand({ scanZips: !flags["no-zips"], }); const difs = dedupeDifs( - buildDifList(prepared, Boolean(flags["include-sources"])) + buildDifList(prepared, { + includeSources: Boolean(flags["include-sources"]), + il2cppMapping: Boolean(flags["il2cpp-mapping"]), + }) ); const missingIds = missingRequestedIds(flags.id, prepared); diff --git a/src/lib/dif/index.ts b/src/lib/dif/index.ts index f8768e18f..feb36e4c0 100644 --- a/src/lib/dif/index.ts +++ b/src/lib/dif/index.ts @@ -15,7 +15,12 @@ import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; -import { Archive, initSync, SourceBundleWriter } from "@sentry/symbolic"; +import { + Archive, + il2cppLineMapping, + initSync, + SourceBundleWriter, +} from "@sentry/symbolic"; import { logger } from "../logger.js"; const log = logger.withTag("dif"); @@ -259,6 +264,10 @@ export type SourceBundleResult = { * @param objectName - Name stamped on the bundle (typically the input file name). * @param readSource - Supplies source content for a referenced path, or `null` to skip. * Invoked synchronously, so it must read synchronously (e.g. `readFileSync`). + * @param options - Optional behavior flags. + * @param options.collectIl2cppSources - When `true`, also include the C# source + * files referenced by Unity IL2CPP `source_info` markers in the object's + * generated C++ (used together with `--il2cpp-mapping`). * @returns The bundle bytes (or `null` if empty), the object's debug id, and the * number of files included. * @throws If the buffer cannot be parsed, or if `readSource` throws. @@ -266,7 +275,8 @@ export type SourceBundleResult = { export function createSourceBundle( data: Uint8Array, objectName: string, - readSource: (path: string) => Uint8Array | null + readSource: (path: string) => Uint8Array | null, + options?: { collectIl2cppSources?: boolean } ): SourceBundleResult { ensureInitialized(); const archive = new Archive(data); @@ -279,6 +289,10 @@ export function createSourceBundle( let fileCount = 0; const writer = new SourceBundleWriter(); + // Must be set before the one-shot writeObject() consumes the writer. + if (options?.collectIl2cppSources) { + writer.collectIl2cppSources = true; + } // The filter runs before each file; we include everything the object // references and let the provider decide availability (returning null skips). const filter = (_path: string): boolean => true; @@ -295,6 +309,56 @@ export function createSourceBundle( return { bundle, debugId: object.debugId, fileCount, objectCount }; } +/** Result of computing a Unity IL2CPP line mapping for a debug information file. */ +export type Il2cppMappingResult = { + /** The serialized IL2CPP C++→C# line-mapping JSON document bytes. */ + mapping: Uint8Array; + /** Debug id of the object the mapping was computed for. */ + debugId: string; +}; + +/** + * Compute a Unity IL2CPP C++→C# line mapping for a debug information file. + * + * Unity's IL2CPP transpiles C# to C++, embedding `//` + * markers in the generated C++. This enumerates the C++ source files referenced + * by the object's debug info; for each, `readSource` supplies that file's + * contents (return `null` to skip a path not available locally). The markers are + * parsed into a C++→C# mapping serialized as a JSON document — the format Sentry + * consumes for IL2CPP symbolication — which can be uploaded as a separate DIF. + * + * The mapping is computed for the single object chosen by + * {@link selectBundledObject}, matching {@link createSourceBundle}. Nothing is + * read from disk by this function itself; `readSource` performs all I/O. + * + * @param data - The full contents of the debug information file. + * @param readSource - Supplies C++ source content for a referenced path, or + * `null` to skip. Invoked synchronously (e.g. `readFileSync`). + * @returns The serialized mapping bytes plus the object's debug id, or `null` + * when the archive has no objects or the object references no IL2CPP + * `source_info` markers (an empty mapping). + * @throws If the buffer cannot be parsed, or if `readSource` throws. + */ +export function createIl2cppLineMapping( + data: Uint8Array, + readSource: (path: string) => Uint8Array | null +): Il2cppMappingResult | null { + ensureInitialized(); + const archive = new Archive(data); + const object = selectBundledObject(archive.objects()); + if (!object) { + return null; + } + const mapping = il2cppLineMapping( + object, + (path: string) => readSource(path) ?? undefined + ); + if (!mapping) { + return null; + } + return { mapping, debugId: object.debugId }; +} + /** A source file referenced by an object, with any resolved descriptor metadata. */ export type DifSourceFile = { /** Absolute path recorded in the debug info. */ diff --git a/test/commands/debug-files/upload.test.ts b/test/commands/debug-files/upload.test.ts index 7a8b2c33d..5ee5e64d0 100644 --- a/test/commands/debug-files/upload.test.ts +++ b/test/commands/debug-files/upload.test.ts @@ -98,6 +98,23 @@ async function writeDifFixture(fixture: string, name: string): Promise { return path; } +/** + * Write a Breakpad file referencing an on-disk generated C++ source that carries + * an IL2CPP `source_info` marker, so `--il2cpp-mapping` can resolve a mapping. + */ +async function writeIl2cppFixture(): Promise { + const cppPath = join(tempDir, "Game.cpp"); + await writeFile(cppPath, "//\nint generated = 0;\n"); + const sym = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + `FILE 0 ${cppPath}`, + "FUNC 1000 10 0 main", + "1000 10 42 0", + ].join("\n"); + await writeFile(join(tempDir, "example.sym"), sym); +} + /** Run `debug-files upload` and capture stdout + exit code. */ async function runUpload( args: string[] @@ -585,4 +602,44 @@ describe("sentry debug-files upload", () => { expect(difs[0]?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); expect(difs[0]?.content).toBeInstanceOf(Buffer); }); + + // ── IL2CPP line mappings ───────────────────────────────────────── + + test("--il2cpp-mapping produces a separate il2cpp DIF (dry-run)", async () => { + await writeIl2cppFixture(); + const { output, exitCode } = await runUpload([ + tempDir, + "--il2cpp-mapping", + "--no-upload", + "--json", + ]); + expect(exitCode).toBe(0); + const files = JSON.parse(output).files as { name: string }[]; + expect(files.some((f) => f.name === "example.sym")).toBe(true); + expect(files.some((f) => f.name === "example.sym.il2cpp")).toBe(true); + }); + + test("no il2cpp DIF is produced without --il2cpp-mapping", async () => { + await writeIl2cppFixture(); + const { output } = await runUpload([tempDir, "--no-upload", "--json"]); + const files = JSON.parse(output).files as { name: string }[]; + expect(files.some((f) => f.name.endsWith(".il2cpp"))).toBe(false); + }); + + test("--il2cpp-mapping threads the mapping DIF through to uploadDebugFiles", async () => { + process.env.SENTRY_ORG = "test-org"; + process.env.SENTRY_PROJECT = "test-project"; + await writeIl2cppFixture(); + const spy = vi + .spyOn(debugFilesApi, "uploadDebugFiles") + .mockResolvedValue([]); + + await runUpload([tempDir, "--il2cpp-mapping"]); + expect(spy).toHaveBeenCalledTimes(1); + const difs = spy.mock.calls[0]?.[0]?.difs ?? []; + const il2cpp = difs.find((d) => d.name === "example.sym.il2cpp"); + expect(il2cpp).toBeDefined(); + expect(il2cpp?.debugId).toBe(KNOWN_DEBUG_ID); + expect(il2cpp?.content).toBeInstanceOf(Buffer); + }); }); diff --git a/test/lib/dif/il2cpp.test.ts b/test/lib/dif/il2cpp.test.ts new file mode 100644 index 000000000..0fb63c04f --- /dev/null +++ b/test/lib/dif/il2cpp.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for Unity IL2CPP line-mapping extraction and IL2CPP source collection. + * + * Uses text Breakpad fixtures (which reference source files via `FILE` records) + * plus synthetic C++/C# provider content, so no binary fixtures are needed. The + * extraction runs through the `@sentry/symbolic` WASM module. + */ + +import { unzipSync } from "fflate"; +import { describe, expect, test } from "vitest"; +import { + createIl2cppLineMapping, + createSourceBundle, +} from "../../../src/lib/dif/index.js"; + +const CPP_PATH = "/src/Game.cpp"; +const CS_PATH = "/src/Game.cs"; +const KNOWN_DEBUG_ID = "0f13a5da-412a-fbf7-c866-2048f3294f3d"; + +/** Breakpad object referencing one C++ source file via a FILE record. */ +const BREAKPAD_WITH_CPP = [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "INFO CODE_ID DAA5130F2A41F7FBC8662048F3294F3D439CA7FF", + `FILE 0 ${CPP_PATH}`, + "FUNC 1000 10 0 main", + "1000 10 42 0", +].join("\n"); + +/** Generated C++ carrying an IL2CPP source_info marker mapping to a C# file. */ +const CPP_WITH_SOURCE_INFO = `//\nint generated = 0;\n`; + +function bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +describe("createIl2cppLineMapping", () => { + test("computes a mapping from source_info markers via the provider", () => { + const calls: string[] = []; + const result = createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), (path) => { + calls.push(path); + return bytes(CPP_WITH_SOURCE_INFO); + }); + expect(calls).toContain(CPP_PATH); + expect(result).not.toBeNull(); + if (!result) { + return; + } + expect(result.debugId).toBe(KNOWN_DEBUG_ID); + const doc = JSON.parse(new TextDecoder().decode(result.mapping)) as Record< + string, + Record + >; + expect(doc[CPP_PATH]?.[CS_PATH]).toBeDefined(); + }); + + test("returns null when no referenced source is available", () => { + expect( + createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), () => null) + ).toBeNull(); + }); + + test("returns null when the C++ has no source_info markers", () => { + expect( + createIl2cppLineMapping(bytes(BREAKPAD_WITH_CPP), () => + bytes("int plain = 0;\n") + ) + ).toBeNull(); + }); +}); + +describe("createSourceBundle collectIl2cppSources", () => { + const sources: Record = { + [CPP_PATH]: bytes(CPP_WITH_SOURCE_INFO), + [CS_PATH]: bytes("class Game {}\n"), + }; + const read = (p: string): Uint8Array | null => sources[p] ?? null; + + function bundleFiles(bundle: Uint8Array | null): string[] { + if (!bundle) { + return []; + } + return Object.keys(unzipSync(bundle)).filter((f) => f.startsWith("files/")); + } + + test("includes referenced C# sources when enabled", () => { + const result = createSourceBundle( + bytes(BREAKPAD_WITH_CPP), + "example", + read, + { + collectIl2cppSources: true, + } + ); + const files = bundleFiles(result.bundle); + expect(files).toContain(`files${CPP_PATH}`); + expect(files).toContain(`files${CS_PATH}`); + }); + + test("omits C# sources when disabled", () => { + const result = createSourceBundle( + bytes(BREAKPAD_WITH_CPP), + "example", + read + ); + const files = bundleFiles(result.bundle); + expect(files).toContain(`files${CPP_PATH}`); + expect(files).not.toContain(`files${CS_PATH}`); + }); +});