diff --git a/package.json b/package.json index b941e8855..14794a7d5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@sentry/core": "10.50.0", "@sentry/node-core": "10.50.0", "@sentry/sqlish": "^1.0.1", - "@sentry/symbolic": "13.5.0", + "@sentry/symbolic": "13.7.0", "@spotlightjs/spotlight": "^4.11.7", "@stricli/auto-complete": "^1.2.8", "@stricli/core": "1.2.7", diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index e5751a643..9f58d73ef 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01"` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01"` **Examples:** diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 6361144da..448963e18 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -37,7 +37,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/plugins/sentry-cli/skills/sentry-cli/references/explore.md index 0ae769765..cef449151 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -24,7 +24,7 @@ Query aggregate event data (Explore) - `-s, --sort - Sort field (prefix with - for desc, e.g., "-count()")` - `-e, --environment ... - Replay environment filter for --dataset replays (repeatable, comma-separated)` - `-n, --limit - Number of rows (1-1000) - (default: "25")` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "24h")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "24h")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index cfeee044b..5e3fafcf7 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: recommended, date, new, freq, user (default: recommended on sentry.io, else date)` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -92,7 +92,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/log.md b/plugins/sentry-cli/skills/sentry-cli/references/log.md index 98f03674c..8e2489702 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01"` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/plugins/sentry-cli/skills/sentry-cli/references/replay.md index c7c4c5bed..7b4abb065 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -20,7 +20,7 @@ List recent Session Replays - `-q, --query - Search query (Sentry replay search syntax)` - `-e, --environment ... - Filter by environment (repeatable, comma-separated)` - `-s, --sort - Sort by: date, oldest, duration, errors, activity, or a raw replay sort field - (default: "date")` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/span.md b/plugins/sentry-cli/skills/sentry-cli/references/span.md index 613bb86cd..7e1f41d8b 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/plugins/sentry-cli/skills/sentry-cli/references/trace.md index ed39eea92..07924c69b 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-05-01..2026-06-01", ">=2026-05-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-06-01..2026-07-01", ">=2026-06-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "level:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8bb795f4..5f611510e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^1.0.1 version: 1.0.1(react@19.2.7) '@sentry/symbolic': - specifier: 13.5.0 - version: 13.5.0 + specifier: 13.7.0 + version: 13.7.0 '@spotlightjs/spotlight': specifier: ^4.11.7 version: 4.11.7(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.12.27)) @@ -812,8 +812,8 @@ packages: react: optional: true - '@sentry/symbolic@13.5.0': - resolution: {integrity: sha512-92buXpApwcFs5TsglryoPudQJBBQxF9ASOU//iJQSHlGjAVmk38KsqW0xshe//TTXjFdrDkXCQosR7eUV+1Z5Q==} + '@sentry/symbolic@13.7.0': + resolution: {integrity: sha512-FR8CESwUVeMlJgiCHQrV8hWj1bPA/d9Sq2dYHtDDpGakBq7C0BNE1jAtyD41lleVvrKlrAY89NiZJ+JnCftFpA==} '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} @@ -3282,7 +3282,7 @@ snapshots: optionalDependencies: react: 19.2.7 - '@sentry/symbolic@13.5.0': {} + '@sentry/symbolic@13.7.0': {} '@sindresorhus/merge-streams@4.0.0': {} diff --git a/src/commands/debug-files/upload.ts b/src/commands/debug-files/upload.ts index 079a8e868..2f0c3d86a 100644 --- a/src/commands/debug-files/upload.ts +++ b/src/commands/debug-files/upload.ts @@ -11,9 +11,10 @@ * Honors the server-advertised `max_file_size` (oversized files are skipped) * 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. `--symbol-maps` (BCSymbolMap resolution) and - * `--il2cpp-mapping` line mappings are deferred to follow-up PRs (see the - * command's full description). + * 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). */ import { createHash } from "node:crypto"; @@ -173,6 +174,9 @@ 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. * + * Embedded Portable PDBs (extracted from managed PE assemblies during scanning) + * arrive here as ordinary prepared DIFs, so no special handling is needed. + * * Source files are read synchronously from the paths recorded in each object's * debug info; files not present locally are skipped. A bundle is only added * when it contains at least one source file. @@ -454,6 +458,8 @@ export const uploadCommand = buildCommand({ " --no-zips Do not scan inside .zip archives\n\n" + ".zip archives are scanned in place by default; nested archives are not " + "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" + "Usage:\n" + " sentry debug-files upload ./build\n" + " sentry debug-files upload ./symbols.zip\n" + diff --git a/src/lib/dif/index.ts b/src/lib/dif/index.ts index 89bf67158..f8768e18f 100644 --- a/src/lib/dif/index.ts +++ b/src/lib/dif/index.ts @@ -157,6 +157,60 @@ export function peekFormat(data: Uint8Array): string { return Archive.peek(data) ?? "unknown"; } +/** An embedded Portable PDB extracted from a managed PE object. */ +export type EmbeddedPpdbResult = { + /** The decompressed, standalone Portable PDB bytes. */ + ppdb: Uint8Array; + /** Debug id of the PE the Portable PDB was embedded in (its advisory id). */ + debugId: string; +}; + +/** + * Extract a Portable PDB embedded inside a managed Windows PE (.NET assembly). + * + * Managed PE images can carry their Portable PDB debug companion inline (debug + * directory entry type 17, deflate-compressed). This narrows each object in the + * archive to a PE via `ObjectFile.asPe()` and, for the first PE that embeds a + * Portable PDB, decompresses and returns those bytes together with that PE's + * debug id. The returned bytes are themselves a standalone Portable PDB that can + * be parsed and uploaded as a separate debug information file. + * + * Non-PE objects (and PEs without an embedded PPDB) are skipped, so this is safe + * to call unconditionally on any object file. A decompression/extraction error + * for an individual object is swallowed (logged at debug level) and treated as + * "no embedded PPDB", mirroring legacy `sentry-cli`, where a failed extraction + * never aborts the surrounding upload. + * + * @param data - The full contents of the object file (archive). + * @returns The embedded Portable PDB bytes plus the PE's debug id, or `null` + * when no object in the archive embeds a Portable PDB. + * @throws If the buffer cannot be parsed as a known object format. + */ +export function extractEmbeddedPpdb( + data: Uint8Array +): EmbeddedPpdbResult | null { + ensureInitialized(); + const archive = new Archive(data); + for (const object of archive.objects()) { + const pe = object.asPe(); + if (!pe) { + continue; + } + try { + const ppdb = pe.embeddedPpdb(); + if (ppdb) { + return { ppdb, debugId: object.debugId }; + } + } catch (err) { + log.debug( + `Failed to extract embedded Portable PDB for ${object.debugId}`, + err + ); + } + } + return null; +} + /** * Select the single object that source-bundling operates on: the first object * that carries debug info, falling back to the first object in the archive. diff --git a/src/lib/dif/scan.ts b/src/lib/dif/scan.ts index 06c35d961..c9cc24245 100644 --- a/src/lib/dif/scan.ts +++ b/src/lib/dif/scan.ts @@ -19,12 +19,13 @@ */ import { open, readdir, readFile, realpath, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { extname, join } from "node:path"; import { ValidationError } from "../errors.js"; import { logger } from "../logger.js"; import { type DifArchiveInfo, type DifObjectInfo, + extractEmbeddedPpdb, parseDebugFile, peekFormat, selectBundledObject, @@ -472,13 +473,15 @@ export async function prepareDifs( } } - const { dif, oversized } = await prepareFileDif(path, filters, maxFileSize); + const { difs, oversized } = await prepareFileDif( + path, + filters, + maxFileSize + ); if (oversized) { oversizedCount += 1; } - if (dif) { - prepared.push(dif); - } + prepared.push(...difs); } return { prepared, oversizedCount }; @@ -497,43 +500,61 @@ export async function prepareDifs( * @param path - The candidate file path. * @param filters - The resolved filter set. * @param maxFileSize - Size gate in bytes (`0` disables it). - * @returns The prepared DIF (or `null`) and whether it was skipped for size. + * @returns The prepared DIFs (main plus any embedded PPDB; empty when none + * match) and whether the file was skipped for size. */ async function prepareFileDif( path: string, filters: DifFilters, maxFileSize: number -): Promise<{ dif: PreparedDif | null; oversized: boolean }> { +): Promise<{ difs: PreparedDif[]; oversized: boolean }> { const peeked = await peekHeader(path); - if (!(peeked && formatMatches(peeked.format, filters.formats))) { - return { dif: null, oversized: false }; + if (!peeked) { + return { difs: [], oversized: false }; + } + const formatOk = formatMatches(peeked.format, filters.formats); + // A managed PE keeps its debug info in an embedded Portable PDB. When the + // filter accepts `portablepdb`, read PE files even if the `pe` format itself + // is excluded, so the embedded PPDB can still be extracted; difFromBuffer then + // drops the PE object and keeps only the matching PPDB. + const readForPpdb = + peeked.format === "pe" && formatMatches("portablepdb", filters.formats); + if (!(formatOk || readForPpdb)) { + return { difs: [], oversized: false }; } // Gate on size before the full read so an oversized file is never buffered. - // Only recognised debug files of a requested format reach here, so an - // oversized skip means a real, requested DIF was too large. if (maxFileSize > 0 && peeked.size > maxFileSize) { - log.warn( - `Skipping ${path}: size ${peeked.size} exceeds maximum file size ${maxFileSize}` + // Oversized drives the command's exit code only for files of a requested + // format; a PE read solely to extract its PPDB is not itself a requested + // match, so it must not inflate the oversized count. + if (formatOk) { + log.warn( + `Skipping ${path}: size ${peeked.size} exceeds maximum file size ${maxFileSize}` + ); + return { difs: [], oversized: true }; + } + log.debug( + `Skipping ${path} for embedded PPDB extraction: size ${peeked.size} exceeds maximum file size ${maxFileSize}` ); - return { dif: null, oversized: true }; + return { difs: [], oversized: false }; } - return { dif: await readMatchedDif(path, filters), oversized: false }; + // readMatchedDif reports an oversized embedded PPDB (of a requested type) so + // it too drives the exit code, matching the on-disk size gate above. + return await readMatchedDif(path, filters, maxFileSize); } /** - * Parse an in-memory object buffer and return it as a {@link PreparedDif} when - * at least one contained object passes the per-object filters, or `null` when - * the buffer is empty, unparseable, or has no matching object. Pure (no I/O); - * parse failures are logged at debug level. Shared by the on-disk path - * ({@link readMatchedDif}) and in-memory ZIP entries - * ({@link difFromCandidateBuffer}). + * Parse an in-memory object buffer into a single {@link PreparedDif} when at + * least one contained object passes the per-object filters, or `null` when the + * buffer is empty, unparseable, or has no matching object. Pure (no I/O); parse + * failures are logged at debug level. * * @param displayPath - Path used for naming and logs (an on-disk path or a * synthetic `"/"` path). * @param content - The object bytes. * @param filters - The resolved filter set. */ -function difFromBuffer( +function matchedDif( displayPath: string, content: Buffer, filters: DifFilters @@ -566,58 +587,176 @@ function difFromBuffer( } /** - * Fully read and parse a single candidate file, returning it as a - * {@link PreparedDif} when at least one contained object passes the per-object - * filters, or `null` when the file is unreadable, unparseable, empty, or has no - * matching object. Read/parse failures are logged at debug level — a scanned - * tree contains many non-object files. + * Extract a Portable PDB embedded in a managed PE buffer and return it as a + * synthetic {@link PreparedDif}, or `null` when there is none, it is filtered + * out, or it exceeds the size cap. + * + * A managed PE's debug info lives entirely in its embedded Portable PDB, so the + * PE object itself usually carries no debug features and would be dropped by the + * feature filter. The PPDB is therefore extracted independently of whether the + * PE passed the filters (mirroring legacy `sentry-cli`), and the standalone PPDB + * bytes are then run through the same per-object filters as any other file — so + * it correctly honors `--type portablepdb`, feature, and `--id` filters — and + * named `.pdb` after the PE. Extraction/decompression errors are swallowed + * (logged at debug level) so a malformed embed never aborts the scan. + * + * @param displayPath - The PE's path (used to derive the `.pdb` name and logs). + * @param content - The PE object bytes. + * @param filters - The resolved filter set (applied to the extracted PPDB). + * @param maxFileSize - Size gate in bytes for the extracted PPDB (`0` disables). + * @returns The extracted PPDB DIF (or `null`) and whether it was skipped for + * exceeding the size cap while a Portable PDB was a requested type (so the + * caller can fold it into the exit-driving oversized count). + */ +function embeddedPpdbDif( + displayPath: string, + content: Buffer, + filters: DifFilters, + maxFileSize: number +): { dif: PreparedDif | null; oversized: boolean } { + // Only PE images can embed a Portable PDB. Cheap-gate on the header so the + // vast majority of scanned files (ELF/Mach-O/Breakpad/...) skip the full + // second parse that extraction would otherwise perform on every buffer. + const header = + content.length > PEEK_HEADER_BYTES + ? new Uint8Array(content.subarray(0, PEEK_HEADER_BYTES)) + : new Uint8Array(content); + if (peekFormat(header) !== "pe") { + return { dif: null, oversized: false }; + } + let extracted: ReturnType; + try { + extracted = extractEmbeddedPpdb(new Uint8Array(content)); + } catch (err) { + log.debug( + `Could not extract embedded Portable PDB from ${displayPath}`, + err + ); + return { dif: null, oversized: false }; + } + if (!extracted) { + return { dif: null, oversized: false }; + } + if (maxFileSize > 0 && extracted.ppdb.byteLength > maxFileSize) { + log.warn( + `Skipping embedded Portable PDB from ${displayPath}: size ${extracted.ppdb.byteLength} exceeds maximum file size ${maxFileSize}` + ); + // Only count toward the oversized total when a Portable PDB is actually a + // requested type; otherwise it would never have been uploaded anyway, so it + // must not turn an empty queue into a spurious "files too large" failure. + return { + dif: null, + oversized: formatMatches("portablepdb", filters.formats), + }; + } + const ext = extname(displayPath); + const ppdbPath = `${displayPath.slice(0, displayPath.length - ext.length)}.pdb`; + return { + dif: matchedDif(ppdbPath, Buffer.from(extracted.ppdb), filters), + oversized: false, + }; +} + +/** + * Parse an in-memory object buffer into uploadable debug files: the main DIF + * (when it matches) plus, for managed PE assemblies, a separate Portable PDB + * DIF extracted from the PE. Shared by the on-disk path ({@link readMatchedDif}) + * and in-memory ZIP entries ({@link difFromCandidateBuffer}). + * + * @param displayPath - Path used for naming and logs (an on-disk path or a + * synthetic `"/"` path). + * @param content - The object bytes. + * @param filters - The resolved filter set. + * @param maxFileSize - Size gate for any extracted embedded PPDB (`0` disables). + * @returns The prepared DIFs plus whether an extracted embedded PPDB of a + * requested type was skipped for size. + */ +function difFromBuffer( + displayPath: string, + content: Buffer, + filters: DifFilters, + maxFileSize: number +): { difs: PreparedDif[]; oversized: boolean } { + const difs: PreparedDif[] = []; + const main = matchedDif(displayPath, content, filters); + if (main) { + difs.push(main); + } + const ppdb = embeddedPpdbDif(displayPath, content, filters, maxFileSize); + if (ppdb.dif) { + difs.push(ppdb.dif); + } + return { difs, oversized: ppdb.oversized }; +} + +/** + * Fully read and parse a single candidate file into uploadable debug files (the + * main DIF plus any embedded Portable PDB), or an empty result when the file is + * unreadable, unparseable, empty, or has no matching object. Read/parse failures + * are logged at debug level — a scanned tree contains many non-object files. * * @param path - The candidate file path (already format- and size-gated). * @param filters - The resolved filter set. + * @param maxFileSize - Size gate for any extracted embedded PPDB (`0` disables). + * @returns The prepared DIFs plus whether an extracted embedded PPDB of a + * requested type was skipped for size. */ async function readMatchedDif( path: string, - filters: DifFilters -): Promise { + filters: DifFilters, + maxFileSize: number +): Promise<{ difs: PreparedDif[]; oversized: boolean }> { let content: Buffer; try { content = await readFile(path); } catch (err) { log.debug(`Skipping unreadable file: ${path}`, err); - return null; + return { difs: [], oversized: false }; } - return difFromBuffer(path, content, filters); + return difFromBuffer(path, content, filters, maxFileSize); } /** - * Format-gate and parse an in-memory ZIP entry into a {@link PreparedDif}. + * Format-gate and parse an in-memory ZIP entry into uploadable debug files. * * Applies the cheap, header-derivable `--type` (format) filter before invoking * the full parser, mirroring the on-disk peek optimization so non-matching - * entries are not handed to the WASM parser. Returns `null` for unrecognised, - * non-matching, or non-object entries. + * entries are not handed to the WASM parser. PE entries are still parsed when + * the filter accepts `portablepdb`, so an embedded Portable PDB can be extracted + * even if the `pe` format itself is excluded. Returns an empty array for + * unrecognised, non-matching, or non-object entries. * * @param displayPath - Synthetic `"/"` path for naming and logs. * @param content - The decompressed entry bytes (already size-gated). * @param filters - The resolved filter set. + * @param maxFileSize - Size gate for any extracted embedded PPDB (`0` disables). */ function difFromCandidateBuffer( displayPath: string, content: Buffer, - filters: DifFilters -): PreparedDif | null { + filters: DifFilters, + maxFileSize: number +): PreparedDif[] { if (content.length === 0) { - return null; + return []; } const header = content.length > PEEK_HEADER_BYTES ? new Uint8Array(content.subarray(0, PEEK_HEADER_BYTES)) : new Uint8Array(content); const format = peekFormat(header); - if (format === "unknown" || !formatMatches(format, filters.formats)) { - return null; + if (format === "unknown") { + return []; + } + const formatOk = formatMatches(format, filters.formats); + const readForPpdb = + format === "pe" && formatMatches("portablepdb", filters.formats); + if (!(formatOk || readForPpdb)) { + return []; } - return difFromBuffer(displayPath, content, filters); + // A ZIP entry's oversized embedded PPDB stays advisory (not counted), matching + // how oversized ZIP entries are handled elsewhere in this file. + return difFromBuffer(displayPath, content, filters, maxFileSize).difs; } /** @@ -651,10 +790,9 @@ async function prepareZipDifs( } const prepared: PreparedDif[] = []; for (const entry of zip.entries) { - const dif = difFromCandidateBuffer(entry.path, entry.content, filters); - if (dif) { - prepared.push(dif); - } + prepared.push( + ...difFromCandidateBuffer(entry.path, entry.content, filters, maxFileSize) + ); } return prepared; } diff --git a/test/commands/debug-files/upload.test.ts b/test/commands/debug-files/upload.test.ts index 02ac4ba40..7a8b2c33d 100644 --- a/test/commands/debug-files/upload.test.ts +++ b/test/commands/debug-files/upload.test.ts @@ -7,6 +7,7 @@ * on `uploadDebugFiles`. */ +import { readFileSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -84,6 +85,19 @@ async function writeBreakpadZip(name = "symbols.zip"): Promise { return path; } +/** Debug id of the managed PE fixture (shared by its embedded Portable PDB). */ +const EMBEDDED_PE_DEBUG_ID = "d8eb7dca-4883-4b10-a1f7-048ea1ea388b-cfb0fc89"; + +/** Copy a committed binary DIF fixture into `tempDir` under `name`. */ +async function writeDifFixture(fixture: string, name: string): Promise { + const bytes = readFileSync( + new URL(`../../fixtures/dif/${fixture}`, import.meta.url) + ); + const path = join(tempDir, name); + await writeFile(path, bytes); + return path; +} + /** Run `debug-files upload` and capture stdout + exit code. */ async function runUpload( args: string[] @@ -492,4 +506,83 @@ describe("sentry debug-files upload", () => { expect(exitCode).toBe(0); expect(JSON.parse(output).files).toHaveLength(0); }); + + // ── Embedded Portable PDB extraction ───────────────────────────── + + test("extracts an embedded PPDB as a separate .pdb DIF (dry-run)", async () => { + // The managed PE carries no native debug features of its own, so only the + // extracted Portable PDB is queued, named after the PE. + await writeDifFixture("embedded-ppdb.dll", "App.dll"); + const { output, exitCode } = await runUpload([ + tempDir, + "--no-upload", + "--json", + ]); + expect(exitCode).toBe(0); + const files = JSON.parse(output).files as { + name: string; + debugId?: string; + }[]; + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe("App.pdb"); + expect(files[0]?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + }); + + test("--type portablepdb extracts a PE's embedded Portable PDB", async () => { + // Even though the `pe` format is excluded by the filter, the PE is still + // read so its embedded (portablepdb) companion can be extracted. + await writeDifFixture("embedded-ppdb.dll", "App.dll"); + const { output, exitCode } = await runUpload([ + tempDir, + "--type", + "portablepdb", + "--no-upload", + "--json", + ]); + expect(exitCode).toBe(0); + const files = JSON.parse(output).files as { name: string }[]; + expect(files).toHaveLength(1); + expect(files[0]?.name).toBe("App.pdb"); + }); + + test("--type elf ignores a PE with an embedded PPDB", async () => { + await writeDifFixture("embedded-ppdb.dll", "App.dll"); + const { output, exitCode } = await runUpload([ + tempDir, + "--type", + "elf", + "--no-upload", + "--json", + ]); + expect(exitCode).toBe(0); + expect(JSON.parse(output).files).toHaveLength(0); + }); + + test("a PE without an embedded PPDB yields nothing", async () => { + await writeDifFixture("pe-no-ppdb.dll", "Plain.dll"); + const { output, exitCode } = await runUpload([ + tempDir, + "--no-upload", + "--json", + ]); + expect(exitCode).toBe(0); + expect(JSON.parse(output).files).toHaveLength(0); + }); + + test("passes the extracted PPDB through to uploadDebugFiles", async () => { + process.env.SENTRY_ORG = "test-org"; + process.env.SENTRY_PROJECT = "test-project"; + await writeDifFixture("embedded-ppdb.dll", "App.dll"); + const spy = vi + .spyOn(debugFilesApi, "uploadDebugFiles") + .mockResolvedValue([]); + + await runUpload([tempDir]); + expect(spy).toHaveBeenCalledTimes(1); + const difs = spy.mock.calls[0]?.[0]?.difs ?? []; + expect(difs).toHaveLength(1); + expect(difs[0]?.name).toBe("App.pdb"); + expect(difs[0]?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + expect(difs[0]?.content).toBeInstanceOf(Buffer); + }); }); diff --git a/test/fixtures/dif/embedded-ppdb.dll b/test/fixtures/dif/embedded-ppdb.dll new file mode 100644 index 000000000..12f052878 Binary files /dev/null and b/test/fixtures/dif/embedded-ppdb.dll differ diff --git a/test/fixtures/dif/pe-no-ppdb.dll b/test/fixtures/dif/pe-no-ppdb.dll new file mode 100644 index 000000000..628a7e399 Binary files /dev/null and b/test/fixtures/dif/pe-no-ppdb.dll differ diff --git a/test/lib/dif/embedded-ppdb.test.ts b/test/lib/dif/embedded-ppdb.test.ts new file mode 100644 index 000000000..984693333 --- /dev/null +++ b/test/lib/dif/embedded-ppdb.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for embedded Portable PDB extraction from managed PE assemblies. + * + * Uses small committed .NET PE fixtures (from the Sentry sample console app): + * one that embeds a Portable PDB and one that does not. Extraction runs through + * the `@sentry/symbolic` WASM module, so this also exercises the `asPe()` → + * `embeddedPpdb()` binding end-to-end. + */ + +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { + extractEmbeddedPpdb, + parseDebugFile, +} from "../../../src/lib/dif/index.js"; + +/** Read a committed binary DIF fixture as raw bytes. */ +function readFixture(name: string): Uint8Array { + return new Uint8Array( + readFileSync(new URL(`../../fixtures/dif/${name}`, import.meta.url)) + ); +} + +/** Debug id of the managed PE fixture (shared by its embedded Portable PDB). */ +const EMBEDDED_PE_DEBUG_ID = "d8eb7dca-4883-4b10-a1f7-048ea1ea388b-cfb0fc89"; + +describe("extractEmbeddedPpdb", () => { + test("extracts an embedded Portable PDB from a managed PE", () => { + const result = extractEmbeddedPpdb(readFixture("embedded-ppdb.dll")); + expect(result).not.toBeNull(); + expect(result?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + expect(result?.ppdb.byteLength).toBeGreaterThan(0); + }); + + test("the extracted bytes are themselves a standalone Portable PDB", () => { + const result = extractEmbeddedPpdb(readFixture("embedded-ppdb.dll")); + expect(result).not.toBeNull(); + if (!result) { + return; + } + const parsed = parseDebugFile(result.ppdb); + expect(parsed.fileFormat).toBe("portablepdb"); + // The embedded PPDB carries the same debug id as its parent PE, which is + // why using the PE's debug id as the extracted DIF's advisory id is correct. + expect(parsed.objects[0]?.debugId).toBe(EMBEDDED_PE_DEBUG_ID); + }); + + test("returns null for a PE without an embedded Portable PDB", () => { + expect(extractEmbeddedPpdb(readFixture("pe-no-ppdb.dll"))).toBeNull(); + }); + + test("returns null for a non-PE object", () => { + const breakpad = new TextEncoder().encode( + [ + "MODULE Linux x86_64 0F13A5DA412AFBF7C8662048F3294F3D0 example", + "FUNC 1000 10 0 main", + ].join("\n") + ); + expect(extractEmbeddedPpdb(breakpad)).toBeNull(); + }); +});