From dbf4a335bc845bd0052d82b523f11fb67e184ef4 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 25 Aug 2026 08:01:26 +0000 Subject: [PATCH 1/5] feat(build): support dSYMs with IPA uploads Add a repeatable --dsym flag to `build upload` that embeds dSYM bundles into the synthetic XCArchive built from an IPA. IPAs often omit dSYMs after app thinning, so this lets clients attach debug symbols without constructing an XCArchive themselves. Each --dsym value may be a .dSYM bundle, a directory of bundles, or a ZIP of either. dSYMs only apply to a single IPA upload; using --dsym with a non-IPA build or multiple builds is rejected. Ports getsentry/sentry-cli#3393. Fixes #1428 --- apps/cli-docs/src/fragments/commands/build.md | 7 + packages/cli/src/commands/build/upload.ts | 49 +++- packages/cli/src/lib/build/index.ts | 216 +++++++++++++++++- .../cli/test/commands/build/upload.test.ts | 67 +++++- packages/cli/test/lib/build/index.test.ts | 147 ++++++++++++ 5 files changed, 479 insertions(+), 7 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/build.md b/apps/cli-docs/src/fragments/commands/build.md index ffa76e9143..153521f6b3 100644 --- a/apps/cli-docs/src/fragments/commands/build.md +++ b/apps/cli-docs/src/fragments/commands/build.md @@ -10,6 +10,9 @@ sentry build upload ./app-release.apk sentry build upload ./MyApp.xcarchive sentry build upload ./MyApp.ipa +# Attach dSYMs to an IPA upload (bundle, directory of bundles, or ZIP; repeatable) +sentry build upload ./MyApp.ipa --dsym ./MyApp.app.dSYM --dsym ./Frameworks.dSYMs.zip + # Upload with a build configuration and release notes sentry build upload ./app.aab --build-configuration Release --release-notes "Nightly" @@ -38,6 +41,10 @@ sentry build download 1234567890 --json images (that required native macOS frameworks), so the server sees the raw `.car` rather than a per-image breakdown. XCArchive symlinks and Unix file permissions are preserved. +- `--dsym` attaches debug symbols to an **IPA** upload (IPAs are often missing + dSYMs after app thinning). Each value may be a `.dSYM` bundle, a directory of + bundles, or a ZIP of either, and the flag is repeatable. It only applies when + uploading a single IPA. - Multiple paths may be uploaded at once; the command exits non-zero if any build fails to upload. - Git metadata (commit, branch, PR number, repo) is **auto-collected in CI** diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 170a296d9f..f0443b85a0 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -16,6 +16,7 @@ import { uploadBuild, } from "../../lib/api/preprod-artifacts.js"; import { + collectDsymEntries, detectBuildFormat, normalizeBuildDirectory, normalizeBuildFile, @@ -58,6 +59,7 @@ type UploadFlags = { "build-configuration"?: string; "release-notes"?: string; "install-group"?: string[]; + dsym?: string[]; } & VcsFlags; /** Result for a single uploaded path. */ @@ -103,7 +105,8 @@ async function uploadOne( path: string, org: string, project: string, - metadata: BuildUploadMetadata + metadata: BuildUploadMetadata, + dsymPaths: string[] ): Promise { let info: Awaited>; try { @@ -119,6 +122,12 @@ async function uploadOne( // validation refuses arbitrary directories so a stray `sentry build upload ./` // can't sweep up source, .git/, or secrets. if (info.isDirectory()) { + if (dsymPaths.length > 0) { + throw new ValidationError( + "--dsym can only be used with an IPA upload", + "dsym" + ); + } validateXcarchiveDirectory(path); const normalized = await normalizeBuildDirectory(path, plugin); return await uploadBuild({ org, project, content: normalized, metadata }); @@ -135,8 +144,16 @@ async function uploadOne( let normalized: Buffer; if (format === "ipa") { - normalized = normalizeIpa(content, plugin); + const dsymEntries = + dsymPaths.length > 0 ? await collectDsymEntries(dsymPaths) : []; + normalized = normalizeIpa(content, plugin, dsymEntries); } else if (format === "apk" || format === "aab") { + if (dsymPaths.length > 0) { + throw new ValidationError( + "--dsym can only be used with an IPA upload", + "dsym" + ); + } normalized = normalizeBuildFile(path, content, plugin); } else { throw new ValidationError( @@ -161,6 +178,7 @@ export const uploadCommand = buildCommand({ " sentry build upload ./app-release.apk\n" + " sentry build upload ./MyApp.xcarchive\n" + " sentry build upload ./MyApp.ipa --build-configuration Release\n" + + " sentry build upload ./MyApp.ipa --dsym ./MyApp.app.dSYM\n" + " sentry build upload ./app.aab --install-group qa --install-group beta", }, output: { @@ -197,6 +215,14 @@ export const uploadCommand = buildCommand({ optional: true, variadic: true, }, + dsym: { + kind: "parsed", + parse: String, + brief: + "Path to a dSYM bundle, a directory of dSYM bundles, or a ZIP of either to include with an IPA upload (repeatable)", + optional: true, + variadic: true, + }, "head-sha": { kind: "parsed", parse: String, @@ -274,6 +300,16 @@ export const uploadCommand = buildCommand({ } const { org, project } = resolved; + const dsymPaths = flags.dsym ?? []; + // dSYM inputs apply to the whole command, so their target would be + // ambiguous when a single invocation uploads more than one build. + if (dsymPaths.length > 0 && paths.length > 1) { + throw new ValidationError( + "--dsym can only be used when uploading exactly one IPA file", + "dsym" + ); + } + if (flags["force-git-metadata"] && flags["no-git-metadata"]) { throw new ValidationError( "--force-git-metadata and --no-git-metadata cannot be used together", @@ -301,7 +337,14 @@ export const uploadCommand = buildCommand({ const builds: BuildUploadEntry[] = []; for (const path of paths) { try { - const artifactUrl = await uploadOne(this, path, org, project, metadata); + const artifactUrl = await uploadOne( + this, + path, + org, + project, + metadata, + dsymPaths + ); builds.push({ path, artifactUrl, error: null }); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index 85359f2e8e..ad8506a343 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -24,8 +24,18 @@ */ import { existsSync, readdirSync, statSync } from "node:fs"; -import { lstat, readdir, readFile, readlink } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + readlink, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; import { strToU8, unzipSync, type Zippable, zipSync } from "fflate"; import { CLI_VERSION } from "../constants.js"; import { ValidationError } from "../errors.js"; @@ -343,6 +353,194 @@ export async function normalizeBuildDirectory( return Buffer.from(zipSync(entries)); } +/** A dSYM file collected for inclusion under an XCArchive's `dSYMs/` tree. */ +export type DsymEntry = { + /** Path relative to the `dSYMs/` directory (e.g. `App.app.dSYM/Contents/…`). */ + relPath: string; + /** File bytes. */ + content: Uint8Array; +}; + +/** Whether a path/name ends in a case-insensitive `.dSYM` extension. */ +function hasDsymExtension(name: string): boolean { + return name.toLowerCase().endsWith(".dsym"); +} + +/** Whether a ZIP entry name is macOS archive cruft to ignore during discovery. */ +function isMacosMetadata(name: string): boolean { + return name + .split("/") + .some( + (part) => + part === "__MACOSX" || part === ".DS_Store" || part.startsWith("._") + ); +} + +/** + * Recursively collect a single `.dSYM` bundle's files, keyed under its bundle + * name. Symlinks are rejected (a dSYM should be a plain file tree). + */ +async function collectDsymBundle( + bundlePath: string, + bundleName: string +): Promise { + const out: DsymEntry[] = []; + const walk = async (dir: string, prefix: string): Promise => { + for (const dirent of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, dirent.name); + const rel = prefix ? `${prefix}/${dirent.name}` : dirent.name; + if (dirent.isSymbolicLink()) { + throw new ValidationError( + `Symlinks are not supported in dSYM bundles: ${full}`, + "dsym" + ); + } + if (dirent.isDirectory()) { + await walk(full, rel); + } else if (dirent.isFile()) { + out.push({ relPath: `${bundleName}/${rel}`, content: await readFile(full) }); + } + } + }; + await walk(bundlePath, ""); + return out; +} + +/** + * Find the `.dSYM` bundles under `dir`. If `dir` is itself a `.dSYM` bundle it + * is returned directly. When `allowWrapper` is set and no bundles are found but + * a single nested directory exists (e.g. a ZIP that wraps everything in a + * `dSYMs/` folder), discovery recurses once into it. Mirrors the legacy CLI's + * `discover_dsym_bundles`. + */ +async function discoverDsymBundles( + dir: string, + allowWrapper: boolean +): Promise { + if (hasDsymExtension(dir)) { + return [dir]; + } + const bundles: string[] = []; + const directories: string[] = []; + for (const dirent of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, dirent.name); + if (dirent.isSymbolicLink() && hasDsymExtension(dirent.name)) { + throw new ValidationError( + `dSYM paths cannot be symlinks: ${full}`, + "dsym" + ); + } + if (dirent.isDirectory()) { + if (hasDsymExtension(dirent.name)) { + bundles.push(full); + } else { + directories.push(full); + } + } + } + const [onlyDir] = directories; + if ( + bundles.length === 0 && + allowWrapper && + directories.length === 1 && + onlyDir !== undefined + ) { + return discoverDsymBundles(onlyDir, false); + } + return bundles; +} + +/** Extract a dSYM ZIP into `destDir`, skipping macOS metadata and unsafe paths. */ +async function extractDsymZip( + zipBytes: Uint8Array, + destDir: string +): Promise { + for (const [name, bytes] of Object.entries(unzipSync(zipBytes))) { + if (name.endsWith("/") || name.split("/").includes("..")) { + continue; + } + if (isMacosMetadata(name)) { + continue; + } + const target = join(destDir, name); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, bytes); + } +} + +/** + * Resolve the `--dsym` inputs into a flat list of files to embed under an + * XCArchive's `dSYMs/` directory. Each input may be a `.dSYM` bundle, a + * directory containing bundles, or a ZIP of either. Mirrors the legacy CLI's + * `copy_dsyms`, but collects bytes in memory rather than copying onto disk. + * + * @throws {ValidationError} If an input is missing, a symlink, contains no + * bundles, or two inputs contribute bundles with the same name. + */ +export async function collectDsymEntries( + dsymPaths: string[] +): Promise { + const entries: DsymEntry[] = []; + const seenBundles = new Set(); + + for (const input of dsymPaths) { + let stats: Awaited>; + try { + stats = await lstat(input); + } catch { + throw new ValidationError(`dSYM path does not exist: ${input}`, "dsym"); + } + if (stats.isSymbolicLink()) { + throw new ValidationError( + `dSYM paths cannot be symlinks: ${input}`, + "dsym" + ); + } + + let root = input; + let allowWrapper = false; + let tempDir: string | null = null; + try { + if (stats.isFile()) { + tempDir = await mkdtemp(join(tmpdir(), "sentry-dsym-")); + await extractDsymZip(await readFile(input), tempDir); + root = tempDir; + allowWrapper = true; + } else if (!stats.isDirectory()) { + throw new ValidationError( + `dSYM path must be a .dSYM bundle, a directory containing dSYM bundles, or a ZIP archive: ${input}`, + "dsym" + ); + } + + const bundles = await discoverDsymBundles(root, allowWrapper); + if (bundles.length === 0) { + throw new ValidationError( + `No .dSYM bundles found in ${tempDir ? "ZIP archive" : "directory"}: ${input}`, + "dsym" + ); + } + for (const bundle of bundles) { + const bundleName = basename(bundle); + if (seenBundles.has(bundleName)) { + throw new ValidationError( + `Cannot include multiple dSYM bundles named ${bundleName}`, + "dsym" + ); + } + seenBundles.add(bundleName); + entries.push(...(await collectDsymBundle(bundle, bundleName))); + } + } finally { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + } + } + + return entries; +} + /** Regex matching an IPA's single `Payload/.app/Info.plist` entry. */ const IPA_APP_INFO_PLIST = /^Payload\/([^/]+)\.app\/Info\.plist$/; @@ -392,14 +590,20 @@ function xcarchiveInfoPlist(appName: string): string { * alongside a root `.sentry-cli-metadata.txt`. Mirrors the legacy CLI's * `ipa_to_xcarchive` + `normalize_directory`. * + * Any `dsymEntries` (collected via {@link collectDsymEntries}) are embedded + * under `archive.xcarchive/dSYMs/…` so debug symbols missing from a thinned IPA + * travel with the upload. + * * @param content - The raw IPA bytes. * @param plugin - Optional plugin identity for the metadata file. + * @param dsymEntries - dSYM files to embed under `dSYMs/` (may be empty). * @returns The normalized ZIP bytes. * @throws {Error} If the IPA does not contain exactly one `.app`. */ export function normalizeIpa( content: Uint8Array, - plugin: PipelinePlugin | null + plugin: PipelinePlugin | null, + dsymEntries: DsymEntry[] = [] ): Buffer { const ipaEntries = unzipSync(content); const appName = extractIpaAppName(Object.keys(ipaEntries)); @@ -434,6 +638,12 @@ export function normalizeIpa( `${archiveDir}/Info.plist`, strToU8(xcarchiveInfoPlist(appName)), ]); + for (const entry of dsymEntries) { + archiveEntries.push([ + `${archiveDir}/dSYMs/${entry.relPath}`, + entry.content, + ]); + } archiveEntries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); const entries: Zippable = {}; diff --git a/packages/cli/test/commands/build/upload.test.ts b/packages/cli/test/commands/build/upload.test.ts index e1cda398f1..79f76b53d4 100644 --- a/packages/cli/test/commands/build/upload.test.ts +++ b/packages/cli/test/commands/build/upload.test.ts @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { run } from "@stricli/core"; -import { strToU8, zipSync } from "fflate"; +import { strToU8, unzipSync, zipSync } from "fflate"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { app } from "../../../src/app.js"; import { uploadCommand } from "../../../src/commands/build/upload.js"; @@ -233,6 +233,71 @@ describe("build upload", () => { expect(harness.exitCode).toBeUndefined(); }); + test("embeds --dsym bundles into an IPA upload", async () => { + const ipa = join(tmpDir, "MyApp.ipa"); + await writeFile( + ipa, + zipSync({ + "Payload/MyApp.app/Info.plist": strToU8(""), + "Payload/MyApp.app/MyApp": strToU8("binary"), + }) + ); + const dsym = join(tmpDir, "MyApp.app.dSYM"); + await mkdir(join(dsym, "Contents", "Resources", "DWARF"), { + recursive: true, + }); + await writeFile(join(dsym, "Contents", "Resources", "DWARF", "MyApp"), "d"); + const harness = createContext(); + const func = await uploadCommand.loader(); + + await func.call(harness.context, { dsym: [dsym] }, ipa); + + expect(uploadSpy).toHaveBeenCalledTimes(1); + const opts = uploadSpy.mock.calls[0]?.[0] as { content: Buffer }; + const names = Object.keys(unzipSync(new Uint8Array(opts.content))); + expect( + names.includes( + "archive.xcarchive/dSYMs/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp" + ) + ).toBe(true); + expect(harness.exitCode).toBeUndefined(); + }); + + test("rejects --dsym on a non-IPA build", async () => { + const apk = await writeApk(); + const dsym = join(tmpDir, "MyApp.app.dSYM"); + await mkdir(dsym, { recursive: true }); + const harness = createContext(); + const func = await uploadCommand.loader(); + + await func.call(harness.context, { dsym: [dsym] }, apk); + + expect(uploadSpy).not.toHaveBeenCalled(); + expect(harness.exitCode).toBe(1); + // The per-path error is rendered into the results table (wrapping breaks a + // full-string match, so assert on a stable fragment). + expect(harness.output()).toContain("IPA"); + }); + + test("rejects --dsym when uploading multiple builds", async () => { + const ipa = join(tmpDir, "MyApp.ipa"); + await writeFile( + ipa, + zipSync({ "Payload/MyApp.app/Info.plist": strToU8("") }) + ); + const apk = await writeApk(); + const dsym = join(tmpDir, "MyApp.app.dSYM"); + await mkdir(dsym, { recursive: true }); + const harness = createContext(); + const func = await uploadCommand.loader(); + + // This is a whole-command validation, so it rejects before any upload. + await expect( + func.call(harness.context, { dsym: [dsym] }, ipa, apk) + ).rejects.toThrow("--dsym can only be used when uploading exactly one IPA file"); + expect(uploadSpy).not.toHaveBeenCalled(); + }); + test("uploads the good build but exits non-zero when another fails", async () => { const apk = await writeApk("good.apk"); const bad = join(tmpDir, "bad.txt"); diff --git a/packages/cli/test/lib/build/index.test.ts b/packages/cli/test/lib/build/index.test.ts index a9696c5a7a..a59836b0c2 100644 --- a/packages/cli/test/lib/build/index.test.ts +++ b/packages/cli/test/lib/build/index.test.ts @@ -17,6 +17,7 @@ import { join } from "node:path"; import { strToU8, unzipSync, zipSync } from "fflate"; import { afterEach, describe, expect, test } from "vitest"; import { + collectDsymEntries, detectBuildFormat, extractIpaAppName, normalizeBuildDirectory, @@ -352,4 +353,150 @@ describe("normalizeIpa", () => { "exactly one" ); }); + + test("embeds dSYM entries under the archive's dSYMs/ directory", () => { + const entries = unzipSync( + normalizeIpa(fakeIpaBytes(), null, [ + { + relPath: "MyApp.app.dSYM/Contents/Resources/DWARF/MyApp", + content: strToU8("dwarf"), + }, + ]) + ); + expect( + entries["archive.xcarchive/dSYMs/MyApp.app.dSYM/Contents/Resources/DWARF/MyApp"] + ).toEqual(strToU8("dwarf")); + }); +}); + +describe("collectDsymEntries", () => { + let tmp: string; + + afterEach(() => { + if (tmp) { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + function makeTmp(): string { + tmp = mkdtempSync(join(tmpdir(), "dsym-test-")); + return tmp; + } + + /** Write a minimal `.dSYM` bundle with a single symbols file. */ + function writeDsym(root: string, name: string, contents: string): string { + const bundle = join(root, name); + mkdirSync(join(bundle, "Contents", "Resources", "DWARF"), { + recursive: true, + }); + writeFileSync( + join(bundle, "Contents", "Resources", "DWARF", "sym"), + contents + ); + return bundle; + } + + test("accepts a direct .dSYM bundle and a directory of bundles", async () => { + const root = makeTmp(); + const direct = writeDsym(root, "DemoApp.app.dSYM", "app symbols"); + const symbolsDir = join(root, "Symbols"); + writeDsym(symbolsDir, "DemoFramework.framework.dSYM", "framework symbols"); + writeFileSync(join(symbolsDir, "README.txt"), "ignored"); + + const entries = await collectDsymEntries([direct, symbolsDir]); + const byPath = new Map( + entries.map((e) => [e.relPath, new TextDecoder().decode(e.content)]) + ); + expect( + byPath.get("DemoApp.app.dSYM/Contents/Resources/DWARF/sym") + ).toBe("app symbols"); + expect( + byPath.get("DemoFramework.framework.dSYM/Contents/Resources/DWARF/sym") + ).toBe("framework symbols"); + expect([...byPath.keys()].some((k) => k.includes("README"))).toBe(false); + }); + + test("accepts a bare ZIP and a ZIP wrapping a single directory", async () => { + const root = makeTmp(); + const bareZip = join(root, "bundle.zip"); + writeFileSync( + bareZip, + zipSync({ + "DemoApp.app.dSYM/Contents/Resources/DWARF/sym": strToU8("app"), + }) + ); + const wrappedZip = join(root, "wrapped.zip"); + writeFileSync( + wrappedZip, + zipSync({ + "dSYMs/DemoFramework.framework.dSYM/Contents/Resources/DWARF/sym": + strToU8("fw"), + }) + ); + + const entries = await collectDsymEntries([bareZip, wrappedZip]); + const byPath = new Map( + entries.map((e) => [e.relPath, new TextDecoder().decode(e.content)]) + ); + expect( + byPath.get("DemoApp.app.dSYM/Contents/Resources/DWARF/sym") + ).toBe("app"); + expect( + byPath.get("DemoFramework.framework.dSYM/Contents/Resources/DWARF/sym") + ).toBe("fw"); + }); + + test("ignores macOS metadata inside a ZIP", async () => { + const root = makeTmp(); + const zip = join(root, "symbols.zip"); + writeFileSync( + zip, + zipSync({ + "dSYMs/DemoApp.app.dSYM/Contents/Resources/DWARF/sym": strToU8("sym"), + "__MACOSX/dSYMs/DemoApp.app.dSYM/._sym": strToU8("meta"), + }) + ); + + const entries = await collectDsymEntries([zip]); + expect(entries.every((e) => !e.relPath.includes("__MACOSX"))).toBe(true); + expect(entries.some((e) => e.relPath.includes("._sym"))).toBe(false); + }); + + test("throws when a path does not exist", async () => { + const root = makeTmp(); + await expect( + collectDsymEntries([join(root, "missing.dSYM")]) + ).rejects.toThrow("does not exist"); + }); + + test("throws when a directory contains no bundles", async () => { + const root = makeTmp(); + const empty = join(root, "empty"); + mkdirSync(empty); + writeFileSync(join(empty, "note.txt"), "x"); + await expect(collectDsymEntries([empty])).rejects.toThrow( + "No .dSYM bundles found" + ); + }); + + test("rejects two inputs contributing the same bundle name", async () => { + const root = makeTmp(); + const a = join(root, "a"); + const b = join(root, "b"); + writeDsym(a, "DemoApp.app.dSYM", "one"); + writeDsym(b, "DemoApp.app.dSYM", "two"); + await expect( + collectDsymEntries([join(a, "DemoApp.app.dSYM"), join(b, "DemoApp.app.dSYM")]) + ).rejects.toThrow("multiple dSYM bundles named"); + }); + + test("rejects a symlinked dSYM path", async () => { + const root = makeTmp(); + const real = writeDsym(root, "Real.app.dSYM", "sym"); + const link = join(root, "Link.app.dSYM"); + symlinkSync(real, link); + await expect(collectDsymEntries([link])).rejects.toThrow( + "cannot be symlinks" + ); + }); }); From 6fde1ab7d716f98af80b8b18aab7add6300ea63a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 08:03:10 +0000 Subject: [PATCH 2/5] chore: regenerate docs --- .../plugins/sentry-cli/skills/sentry-cli/references/build.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md index 892bd674d9..4bd53cf990 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md @@ -19,6 +19,7 @@ Upload builds to a project - `--build-configuration - Build configuration for the upload (defaults to the current version)` - `--release-notes - Release notes for the build` - `--install-group ... - Install group(s) for this build (repeatable); builds sharing a group show updates for each other` +- `--dsym ... - Path to a dSYM bundle, a directory of dSYM bundles, or a ZIP of either to include with an IPA upload (repeatable)` - `--head-sha - VCS commit SHA (defaults to the current commit)` - `--base-sha - VCS base commit SHA (defaults to the merge-base with the base ref)` - `--vcs-provider - VCS provider (defaults to the current remote's provider)` @@ -47,6 +48,9 @@ sentry build upload ./app-release.apk sentry build upload ./MyApp.xcarchive sentry build upload ./MyApp.ipa +# Attach dSYMs to an IPA upload (bundle, directory of bundles, or ZIP; repeatable) +sentry build upload ./MyApp.ipa --dsym ./MyApp.app.dSYM --dsym ./Frameworks.dSYMs.zip + # Upload with a build configuration and release notes sentry build upload ./app.aab --build-configuration Release --release-notes "Nightly" From 2ec3f333fc9226aa0b496285a3b12145a03fd4e9 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 10:45:34 +0000 Subject: [PATCH 3/5] fix(build): harden extractDsymZip against Windows traversal and guarantee no symlinks from ZIPs - Normalize both / and \ before the .. check; add resolve-based safeJoin guard. - Post-extract scan rejects any symlink that somehow appears inside the temp dir (even though fflate never emits them). - Addresses the two remaining JamieQ review comments on the ZIP extraction path. --- packages/cli/src/lib/build/index.ts | 30 ++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index ad8506a343..b16d4e9452 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -450,19 +450,33 @@ async function discoverDsymBundles( return bundles; } -/** Extract a dSYM ZIP into `destDir`, skipping macOS metadata and unsafe paths. */ +/** Extract a dSYM ZIP into `destDir`, skipping macOS metadata and unsafe paths. + * Rejects any `..` segment (forward or backslash) and verifies the resolved + * target stays inside `destDir` (covers Windows `..\\`). + */ async function extractDsymZip( zipBytes: Uint8Array, destDir: string ): Promise { + const safeJoin = (base: string, rel: string): string => { + const resolved = resolve(base, rel.replace(/\\/g, "/")); + if (!resolved.startsWith(base + "/") && resolved !== base) { + throw new ValidationError( + `Unsafe path in dSYM ZIP: ${rel}`, + "dsym" + ); + } + return resolved; + }; + for (const [name, bytes] of Object.entries(unzipSync(zipBytes))) { - if (name.endsWith("/") || name.split("/").includes("..")) { + if (name.endsWith("/") || name.split(/[/\\]/).includes("..")) { continue; } if (isMacosMetadata(name)) { continue; } - const target = join(destDir, name); + const target = safeJoin(destDir, name); await mkdir(dirname(target), { recursive: true }); await writeFile(target, bytes); } @@ -504,6 +518,16 @@ export async function collectDsymEntries( if (stats.isFile()) { tempDir = await mkdtemp(join(tmpdir(), "sentry-dsym-")); await extractDsymZip(await readFile(input), tempDir); + // Explicitly reject any symlink that somehow appeared (fflate never + // produces them, but we guarantee the invariant regardless of parser). + for (const entry of await readdir(tempDir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + throw new ValidationError( + `Symlinks are not supported in dSYM ZIPs: ${join(tempDir, entry.name)}`, + "dsym" + ); + } + } root = tempDir; allowWrapper = true; } else if (!stats.isDirectory()) { From 711b9c8c9f12279904d240a02280ec6cbe50bcff Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 10:59:03 +0000 Subject: [PATCH 4/5] fix(build): cross-platform ZIP path guard and real symlink rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the hardcoded base+'/' containment check with relative()/isAbsolute() so the traversal guard works on Windows (resolve emits backslashes there). - Parse the ZIP central directory for S_IFLNK external attributes and reject symlink entries before extraction — fflate's unzipSync drops attributes and would otherwise turn a symlink into a regular file holding the link target. - Drop the post-extract readdir scan (it could never see ZIP symlinks). - Add regression tests for a ZIP symlink entry and a ..\ traversal entry. --- packages/cli/src/lib/build/index.ts | 78 ++++++++++++++++------- packages/cli/test/lib/build/index.test.ts | 42 ++++++++++++ 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index b16d4e9452..7d8821e62c 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -35,7 +35,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { strToU8, unzipSync, type Zippable, zipSync } from "fflate"; import { CLI_VERSION } from "../constants.js"; import { ValidationError } from "../errors.js"; @@ -450,23 +450,61 @@ async function discoverDsymBundles( return bundles; } +/** + * Names of ZIP entries stored as symlinks (Unix mode `S_IFLNK`). + * + * fflate's `unzipSync` drops file attributes, so a symlink entry would silently + * materialize as a regular file holding the link target. We parse the central + * directory ourselves to read the external-attributes field and reject any + * symlink up front, matching the reference implementation. + */ +function zipSymlinkNames(zipBytes: Uint8Array): Set { + const symlinks = new Set(); + const view = new DataView( + zipBytes.buffer, + zipBytes.byteOffset, + zipBytes.byteLength + ); + const decoder = new TextDecoder(); + const CENTRAL_SIG = 0x02014b50; + const S_IFLNK = 0xa000; + for (let i = 0; i + 4 <= zipBytes.length; i++) { + if (view.getUint32(i, true) !== CENTRAL_SIG) { + continue; + } + const nameLen = view.getUint16(i + 28, true); + const extraLen = view.getUint16(i + 30, true); + const commentLen = view.getUint16(i + 32, true); + const externalAttrs = view.getUint32(i + 38, true); + const unixMode = externalAttrs >>> 16; + const nameStart = i + 46; + const name = decoder.decode(zipBytes.subarray(nameStart, nameStart + nameLen)); + if ((unixMode & 0xf000) === S_IFLNK) { + symlinks.add(name); + } + i = nameStart + nameLen + extraLen + commentLen - 1; + } + return symlinks; +} + /** Extract a dSYM ZIP into `destDir`, skipping macOS metadata and unsafe paths. - * Rejects any `..` segment (forward or backslash) and verifies the resolved - * target stays inside `destDir` (covers Windows `..\\`). + * Rejects symlink entries and any path that would escape `destDir` (including + * Windows `..\\` segments). */ async function extractDsymZip( zipBytes: Uint8Array, destDir: string ): Promise { - const safeJoin = (base: string, rel: string): string => { - const resolved = resolve(base, rel.replace(/\\/g, "/")); - if (!resolved.startsWith(base + "/") && resolved !== base) { - throw new ValidationError( - `Unsafe path in dSYM ZIP: ${rel}`, - "dsym" - ); + const base = resolve(destDir); + const symlinks = zipSymlinkNames(zipBytes); + + const safeJoin = (rel: string): string => { + const target = resolve(base, rel.replace(/\\/g, "/")); + const rel2 = relative(base, target); + if (rel2 === "" || rel2.startsWith("..") || isAbsolute(rel2)) { + throw new ValidationError(`Unsafe path in dSYM ZIP: ${rel}`, "dsym"); } - return resolved; + return target; }; for (const [name, bytes] of Object.entries(unzipSync(zipBytes))) { @@ -476,7 +514,13 @@ async function extractDsymZip( if (isMacosMetadata(name)) { continue; } - const target = safeJoin(destDir, name); + if (symlinks.has(name)) { + throw new ValidationError( + `Symlinks are not supported in dSYM ZIPs: ${name}`, + "dsym" + ); + } + const target = safeJoin(name); await mkdir(dirname(target), { recursive: true }); await writeFile(target, bytes); } @@ -518,16 +562,6 @@ export async function collectDsymEntries( if (stats.isFile()) { tempDir = await mkdtemp(join(tmpdir(), "sentry-dsym-")); await extractDsymZip(await readFile(input), tempDir); - // Explicitly reject any symlink that somehow appeared (fflate never - // produces them, but we guarantee the invariant regardless of parser). - for (const entry of await readdir(tempDir, { withFileTypes: true })) { - if (entry.isSymbolicLink()) { - throw new ValidationError( - `Symlinks are not supported in dSYM ZIPs: ${join(tempDir, entry.name)}`, - "dsym" - ); - } - } root = tempDir; allowWrapper = true; } else if (!stats.isDirectory()) { diff --git a/packages/cli/test/lib/build/index.test.ts b/packages/cli/test/lib/build/index.test.ts index a59836b0c2..5bb8e1c177 100644 --- a/packages/cli/test/lib/build/index.test.ts +++ b/packages/cli/test/lib/build/index.test.ts @@ -499,4 +499,46 @@ describe("collectDsymEntries", () => { "cannot be symlinks" ); }); + + test("rejects a symlink entry stored inside a ZIP", async () => { + const root = makeTmp(); + const zip = join(root, "symlink.zip"); + // Craft a ZIP where one entry is stored as a Unix symlink (S_IFLNK) via + // fflate's external-attributes option; unzipSync would silently turn it + // into a regular file, so extraction must reject it. + const symlinkAttrs = ((0o120777 << 16) >>> 0); + writeFileSync( + zip, + zipSync({ + "DemoApp.app.dSYM/Contents/Resources/DWARF/sym": strToU8("real"), + "DemoApp.app.dSYM/evil": [strToU8("/etc/passwd"), { attrs: symlinkAttrs }], + }) + ); + await expect(collectDsymEntries([zip])).rejects.toThrow( + "Symlinks are not supported in dSYM ZIPs" + ); + }); + + test("rejects a ZIP entry that escapes the extraction dir via ..\\", async () => { + const root = makeTmp(); + const zip = join(root, "traversal.zip"); + writeFileSync( + zip, + zipSync({ + "DemoApp.app.dSYM/Contents/Resources/DWARF/sym": strToU8("ok"), + "..\\..\\escape": strToU8("evil"), + }) + ); + // Backslash traversal is skipped, so no bundle-escaping write occurs; the + // valid bundle is still collected. + const entries = await collectDsymEntries([zip]); + expect( + entries.some((e) => e.relPath.includes("escape")) + ).toBe(false); + expect( + entries.some( + (e) => e.relPath === "DemoApp.app.dSYM/Contents/Resources/DWARF/sym" + ) + ).toBe(true); + }); }); From eecf7ff99e767a32e27ebc24a84264e37ac34a20 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Thu, 27 Aug 2026 11:55:48 +0000 Subject: [PATCH 5/5] refactor(build): build dSYM archive entries with map (addresses review) Replaces the push loop for dsymEntries with a spread .map(), per BYK's review comment. --- packages/cli/src/lib/build/index.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/build/index.ts b/packages/cli/src/lib/build/index.ts index 7d8821e62c..861a5e7179 100644 --- a/packages/cli/src/lib/build/index.ts +++ b/packages/cli/src/lib/build/index.ts @@ -696,12 +696,15 @@ export function normalizeIpa( `${archiveDir}/Info.plist`, strToU8(xcarchiveInfoPlist(appName)), ]); - for (const entry of dsymEntries) { - archiveEntries.push([ - `${archiveDir}/dSYMs/${entry.relPath}`, - entry.content, - ]); - } + archiveEntries.push( + ...dsymEntries.map( + (entry) => + [`${archiveDir}/dSYMs/${entry.relPath}`, entry.content] as [ + string, + Uint8Array, + ] + ) + ); archiveEntries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); const entries: Zippable = {};