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/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" 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..861a5e7179 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, 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"; @@ -343,6 +353,252 @@ 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; +} + +/** + * 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 symlink entries and any path that would escape `destDir` (including + * Windows `..\\` segments). + */ +async function extractDsymZip( + zipBytes: Uint8Array, + destDir: string +): Promise { + 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 target; + }; + + for (const [name, bytes] of Object.entries(unzipSync(zipBytes))) { + if (name.endsWith("/") || name.split(/[/\\]/).includes("..")) { + continue; + } + if (isMacosMetadata(name)) { + continue; + } + 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); + } +} + +/** + * 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 +648,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 +696,15 @@ export function normalizeIpa( `${archiveDir}/Info.plist`, strToU8(xcarchiveInfoPlist(appName)), ]); + 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 = {}; 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..5bb8e1c177 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,192 @@ 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" + ); + }); + + 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); + }); });