From fe8565b0619806f9772b4144670f83558c49786a Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 21 Aug 2026 16:51:46 -0400 Subject: [PATCH 01/45] feat(init): add iOS project inspection foundations --- bun.lock | 14 + packages/cli-core/package.json | 2 + .../commands/init/ios/build-settings.test.ts | 496 +++++ .../src/commands/init/ios/build-settings.ts | 973 +++++++++ .../src/commands/init/ios/discovery.ts | 330 +++ .../init/ios/file-transaction.test.ts | 925 +++++++++ .../src/commands/init/ios/file-transaction.ts | 1163 +++++++++++ .../src/commands/init/ios/inspect.test.ts | 589 ++++++ .../cli-core/src/commands/init/ios/inspect.ts | 1662 ++++++++++++++++ .../src/commands/init/ios/pbx.test.ts | 116 ++ .../cli-core/src/commands/init/ios/pbx.ts | 129 ++ .../src/commands/init/ios/products.test.ts | 151 ++ .../src/commands/init/ios/products.ts | 59 + .../src/commands/init/ios/swift.test.ts | 879 ++++++++ .../cli-core/src/commands/init/ios/swift.ts | 1771 +++++++++++++++++ .../src/commands/init/ios/test-helpers.ts | 342 ++++ .../cli-core/src/commands/init/ios/types.ts | 228 +++ 17 files changed, 9829 insertions(+) create mode 100644 packages/cli-core/src/commands/init/ios/build-settings.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/build-settings.ts create mode 100644 packages/cli-core/src/commands/init/ios/discovery.ts create mode 100644 packages/cli-core/src/commands/init/ios/file-transaction.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/file-transaction.ts create mode 100644 packages/cli-core/src/commands/init/ios/inspect.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/inspect.ts create mode 100644 packages/cli-core/src/commands/init/ios/pbx.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/pbx.ts create mode 100644 packages/cli-core/src/commands/init/ios/products.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/products.ts create mode 100644 packages/cli-core/src/commands/init/ios/swift.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/swift.ts create mode 100644 packages/cli-core/src/commands/init/ios/test-helpers.ts create mode 100644 packages/cli-core/src/commands/init/ios/types.ts diff --git a/bun.lock b/bun.lock index 5bd3c08aa..f06179916 100644 --- a/bun.lock +++ b/bun.lock @@ -32,9 +32,11 @@ "clerk": "./src/cli.ts", }, "dependencies": { + "@bacons/xcode": "1.0.0-alpha.33", "@clack/prompts": "^1.7.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^15.0.0", + "@expo/plist": "0.0.18", "@napi-rs/keyring": "^1.3.0", "commander": "^15.0.0", "env-paths": "^4.0.0", @@ -75,6 +77,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bacons/xcode": ["@bacons/xcode@1.0.0-alpha.33", "", { "dependencies": { "@expo/plist": "^0.0.18", "debug": "^4.3.4", "uuid": "^8.3.2" } }, "sha512-+vwXK3mLnW8NOYSHNpCa7sAYR7qWmIHM3xBbBkLailN81F1CdSVsJBH1TAnTMHb+rDEh5ehsZ8AQRNVxvhWR/Q=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], @@ -125,6 +129,8 @@ "@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="], + "@expo/plist": ["@expo/plist@0.0.18", "", { "dependencies": { "@xmldom/xmldom": "~0.7.0", "base64-js": "^1.2.3", "xmlbuilder": "^14.0.0" } }, "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w=="], + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], @@ -305,6 +311,8 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -319,6 +327,8 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -659,12 +669,16 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xmlbuilder": ["xmlbuilder@14.0.0", "", {}, "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index b7db8c47e..989f3d89b 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -17,9 +17,11 @@ "test": "bun test src/ --parallel" }, "dependencies": { + "@bacons/xcode": "1.0.0-alpha.33", "@clack/prompts": "^1.7.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^15.0.0", + "@expo/plist": "0.0.18", "@napi-rs/keyring": "^1.3.0", "commander": "^15.0.0", "env-paths": "^4.0.0", diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts new file mode 100644 index 000000000..03c954b0f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -0,0 +1,496 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { inspectTargetBuildConfigurations } from "./build-settings.ts"; +import type { PbxObject, PbxObjects } from "./pbx.ts"; +import type { IOSDiagnostic } from "./types.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +interface BuildSettingsFixtureOptions { + xcconfig?: string; + includedXCConfig?: string; + projectDirPath?: string; + targetBuildSettings?: Record; + projectConfigurationIds?: string[]; + targetConfigurationIds?: string[]; +} + +async function inspectFixture(options: BuildSettingsFixtureOptions = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-build-settings-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const groupRootDirectory = resolve(root, options.projectDirPath ?? ""); + await mkdir(projectPath, { recursive: true }); + + const objects: PbxObjects = { + "project-list": { + isa: "XCConfigurationList", + buildConfigurations: options.projectConfigurationIds ?? ["project-debug"], + }, + "project-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + buildSettings: { SDKROOT: "iphoneos" }, + }, + "target-list": { + isa: "XCConfigurationList", + buildConfigurations: options.targetConfigurationIds ?? ["target-debug"], + }, + "target-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + ...(options.xcconfig ? { baseConfigurationReference: "target-xcconfig" } : {}), + buildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Example", + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + ...options.targetBuildSettings, + }, + }, + "target-release": { + isa: "XCBuildConfiguration", + name: "Release", + buildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Example", + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + "target-xcconfig": { + isa: "PBXFileReference", + path: "Config/Target.xcconfig", + sourceTree: "", + }, + }; + + if (options.xcconfig) { + await mkdir(join(groupRootDirectory, "Config"), { recursive: true }); + await Bun.write(join(groupRootDirectory, "Config", "Target.xcconfig"), options.xcconfig); + if (options.includedXCConfig != null) { + await Bun.write( + join(groupRootDirectory, "Config", "Included.xcconfig"), + options.includedXCConfig, + ); + } + } + + const projectObject: PbxObject = { + isa: "PBXProject", + projectDirPath: options.projectDirPath ?? "", + buildConfigurationList: "project-list", + }; + const targetObject: PbxObject = { + isa: "PBXNativeTarget", + name: "Example", + productName: "Example", + buildConfigurationList: "target-list", + }; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectTargetBuildConfigurations({ + root, + projectPath, + groupRootDirectory, + projectObject, + targetId: "target", + targetObject, + objects, + parents: new Map(), + diagnostics, + }); + return { configurations, diagnostics, root }; +} + +describe("inspectTargetBuildConfigurations", () => { + test("merges braced inherited values across project and target settings", async () => { + const projectObject: PbxObject = { + isa: "PBXProject", + buildConfigurationList: "project-list", + }; + const targetObject: PbxObject = { + isa: "PBXNativeTarget", + name: "Example", + productName: "Example", + buildConfigurationList: "target-list", + }; + const objects: PbxObjects = { + "project-list": { + isa: "XCConfigurationList", + buildConfigurations: ["project-debug"], + }, + "project-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + buildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "com.example", SDKROOT: "iphoneos" }, + }, + "target-list": { + isa: "XCConfigurationList", + buildConfigurations: ["target-debug"], + }, + "target-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + buildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "${inherited}.MyApp", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + }; + + const configurations = await inspectTargetBuildConfigurations({ + root: "/tmp/Example", + projectPath: "/tmp/Example/Example.xcodeproj", + groupRootDirectory: "/tmp/Example", + projectObject, + targetId: "target", + targetObject, + objects, + parents: new Map(), + diagnostics: [], + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.MyApp", + }); + }); + + test("keeps unsupported Xcode build-setting modifiers unresolved", async () => { + const projectObject: PbxObject = { + isa: "PBXProject", + buildConfigurationList: "project-list", + }; + const targetObject: PbxObject = { + isa: "PBXNativeTarget", + name: "Example", + productName: "Example", + buildConfigurationList: "target-list", + }; + const objects: PbxObjects = { + "project-list": { + isa: "XCConfigurationList", + buildConfigurations: ["project-debug"], + }, + "project-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + buildSettings: { SDKROOT: "iphoneos" }, + }, + "target-list": { + isa: "XCConfigurationList", + buildConfigurations: ["target-debug"], + }, + "target-debug": { + isa: "XCBuildConfiguration", + name: "Debug", + buildSettings: { + PRODUCT_NAME: "My App", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.$(PRODUCT_NAME:rfc1034identifier)", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + }; + const diagnostics: IOSDiagnostic[] = []; + + const configurations = await inspectTargetBuildConfigurations({ + root: "/tmp/Example", + projectPath: "/tmp/Example/Example.xcodeproj", + groupRootDirectory: "/tmp/Example", + projectObject, + targetId: "target", + targetObject, + objects, + parents: new Map(), + diagnostics, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + raw: "com.example.$(PRODUCT_NAME:rfc1034identifier)", + missingVariables: ["PRODUCT_NAME:rfc1034identifier"], + }); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("PRODUCT_NAME:rfc1034identifier"), + }), + ]); + }); + + test("uses projectDirPath for group refs while keeping SRCROOT and PROJECT_DIR at the project container", async () => { + const { configurations, root } = await inspectFixture({ + projectDirPath: "Sources", + xcconfig: [ + "PRODUCT_BUNDLE_IDENTIFIER = com.example.$(PROJECT_NAME)", + "DEVELOPMENT_TEAM = $(PROJECT_DIR)", + "CODE_SIGN_ENTITLEMENTS = $(SRCROOT)/Example.entitlements", + ].join("\n"), + targetBuildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)", + DEVELOPMENT_TEAM: "$(inherited)", + CODE_SIGN_ENTITLEMENTS: "$(inherited)", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Example", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: root, + }); + expect(configurations[0]?.model.entitlementsPath).toMatchObject({ + state: "resolved", + value: join(root, "Example.entitlements"), + }); + }); + + test("surfaces device and simulator conditional build-setting differences", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Base", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]": "com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]": "com.example.Simulator", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk-conditioned build setting"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.conflicting-build-setting", + message: expect.stringContaining( + "iphoneos=com.example.Device, iphonesimulator=com.example.Simulator", + ), + }), + ); + }); + + test("accepts matching device and simulator conditional build settings", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Base", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]": "com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]": "com.example.Native", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Native", + }); + }); + + test("preserves the textual order of xcconfig assignments and includes", async () => { + const includeLast = await inspectFixture({ + xcconfig: [ + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Before", + '#include "Included.xcconfig"', + ].join("\n"), + includedXCConfig: "PRODUCT_BUNDLE_IDENTIFIER = com.example.Included\n", + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + const assignmentLast = await inspectFixture({ + xcconfig: [ + '#include "Included.xcconfig"', + "PRODUCT_BUNDLE_IDENTIFIER = com.example.After", + ].join("\n"), + includedXCConfig: "PRODUCT_BUNDLE_IDENTIFIER = com.example.Included\n", + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + + expect(includeLast.configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Included", + }); + expect(assignmentLast.configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.After", + }); + }); + + test("taints fallback settings when a required xcconfig include is missing", async () => { + const { configurations, diagnostics } = await inspectFixture({ + xcconfig: [ + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Fallback", + '#include "Missing.xcconfig"', + ].join("\n"), + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["required xcconfig include"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("Required xcconfig include"), + }), + ); + }); + + test("clears an earlier unknown-include taint only with a later literal assignment", async () => { + const { configurations } = await inspectFixture({ + xcconfig: [ + '#include "Missing.xcconfig"', + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Resolved", + "TEAM_VALUE = ABCDE12345", + "DEVELOPMENT_TEAM = $(TEAM_VALUE)", + ].join("\n"), + targetBuildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)", + DEVELOPMENT_TEAM: "$(inherited)", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Resolved", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "unresolved", + missingVariables: ["required xcconfig include"], + }); + }); + + test("taints settings for variable include paths but permits a missing literal optional include", async () => { + const variable = await inspectFixture({ + xcconfig: [ + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Fallback", + '#include? "$(CONFIG_DIR)/Optional.xcconfig"', + ].join("\n"), + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + const literalOptional = await inspectFixture({ + xcconfig: [ + '#include? "Missing.xcconfig"', + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Resolved", + ].join("\n"), + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + + expect(variable.configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["variable xcconfig include path"], + }); + expect(variable.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("path contains build-setting variables"), + }), + ); + expect(literalOptional.configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Resolved", + }); + }); + + test("keeps targets with variable-based platform settings as iOS candidates", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "$(UNKNOWN_SDK)", + SUPPORTED_PLATFORMS: "$(UNKNOWN_PLATFORMS)", + IPHONEOS_DEPLOYMENT_TARGET: "", + }, + }); + + expect(configurations[0]?.isIOS).toBe(true); + expect(diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("SDKROOT"), + }), + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("SUPPORTED_PLATFORMS"), + }), + ]), + ); + }); + + test("keeps targets with include-tainted platform settings as iOS candidates", async () => { + const { configurations } = await inspectFixture({ + xcconfig: [ + '#include "Missing.xcconfig"', + "SDKROOT = $(UNKNOWN_SDK)", + "SUPPORTED_PLATFORMS = $(UNKNOWN_PLATFORMS)", + ].join("\n"), + targetBuildSettings: { + SDKROOT: "$(inherited)", + SUPPORTED_PLATFORMS: "$(inherited)", + IPHONEOS_DEPLOYMENT_TARGET: "", + }, + }); + + expect(configurations[0]?.isIOS).toBe(true); + }); + + test("still rejects targets with fully resolved non-iOS platform evidence", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "watchos", + SUPPORTED_PLATFORMS: "watchos watchsimulator", + IPHONEOS_DEPLOYMENT_TARGET: "", + }, + }); + + expect(configurations[0]?.isIOS).toBe(false); + }); + + test("preserves dangling target configurations as blocking placeholders", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetConfigurationIds: ["target-debug", "missing-target-release"], + }); + + expect(configurations).toHaveLength(2); + expect(configurations[1]?.model).toMatchObject({ + name: "Unresolved (missing-target-release)", + bundleIdentifier: { state: "missing" }, + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.dangling-reference", + severity: "error", + message: expect.stringContaining("missing-target-release"), + }), + ); + }); + + test("taints target settings when the project configuration list is incomplete", async () => { + const { configurations, diagnostics } = await inspectFixture({ + projectConfigurationIds: ["project-debug", "missing-project-release"], + targetConfigurationIds: ["target-debug", "target-release"], + }); + + expect(configurations.map((configuration) => configuration.model.bundleIdentifier)).toEqual([ + expect.objectContaining({ + state: "unresolved", + missingVariables: ["incomplete project configuration list"], + }), + expect.objectContaining({ + state: "unresolved", + missingVariables: ["incomplete project configuration list"], + }), + ]); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.dangling-reference", + message: expect.stringContaining("missing-project-release"), + }), + ); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts new file mode 100644 index 000000000..1a350262f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -0,0 +1,973 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve, sep } from "node:path"; +import { parse as parseXCConfig } from "@bacons/xcode/xcconfig"; +import { pathIsSafelyWithinIOSRoot, pathIsWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + asString, + asStringArray, + asStringRecord, + resolvePbxFilePath, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; +import type { + IOSBuildConfiguration, + IOSDiagnostic, + IOSSourceEvidence, + IOSValueResolution, +} from "./types.ts"; + +const MAX_XCCONFIG_DEPTH = 12; +const BUILD_SETTING_VARIABLE = /\$\(([^)]+)\)|\$\{([^}]+)\}/g; +const INSPECTED_BUILD_SETTING_KEYS = [ + "PRODUCT_BUNDLE_IDENTIFIER", + "DEVELOPMENT_TEAM", + "CODE_SIGN_ENTITLEMENTS", + "IPHONEOS_DEPLOYMENT_TARGET", + "SDKROOT", + "SUPPORTED_PLATFORMS", +] as const; +const INSPECTED_BUILD_SETTINGS = new Set(INSPECTED_BUILD_SETTING_KEYS); + +interface BuildContext { + label: "iphoneos" | "iphonesimulator"; + sdk: "iphoneos" | "iphonesimulator"; + arch: "arm64"; +} + +interface BuildSettingsEvaluation { + settings: Record; + /** Unknown inputs are tracked independently for each inspected setting. */ + settingTaints: Map; +} + +type XCConfigOperation = + | { kind: "include"; path: string; optional: boolean } + | { kind: "setting"; key: string; value: string; conditions?: XCConfigCondition[] }; + +const BUILD_CONTEXTS: BuildContext[] = [ + { label: "iphoneos", sdk: "iphoneos", arch: "arm64" }, + { label: "iphonesimulator", sdk: "iphonesimulator", arch: "arm64" }, +]; + +interface XCConfigCondition { + sdk?: string; + arch?: string; + config?: string; +} + +function wildcardMatches(value: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*"); + return new RegExp(`^${escaped}$`, "i").test(value); +} + +function conditionsMatch( + conditions: XCConfigCondition[] | undefined, + configuration: string, + context: BuildContext, +): boolean { + return (conditions ?? []).every((condition) => { + if (condition.sdk && !wildcardMatches(context.sdk, condition.sdk)) return false; + if (condition.arch && !wildcardMatches(context.arch, condition.arch)) return false; + if (condition.config && !wildcardMatches(configuration, condition.config)) return false; + return true; + }); +} + +function applySettings(base: Record, next: Record): void { + for (const [key, value] of Object.entries(next)) { + base[key] = value.replace(/\$(?:\(inherited\)|\{inherited\})/gi, base[key] ?? "").trim(); + } +} + +function cloneEvaluation(evaluation: BuildSettingsEvaluation): BuildSettingsEvaluation { + return { + settings: { ...evaluation.settings }, + settingTaints: cloneSettingTaints(evaluation.settingTaints), + }; +} + +function taintInspectedSettings(evaluation: BuildSettingsEvaluation, taint: string): void { + for (const key of INSPECTED_BUILD_SETTING_KEYS) { + addSettingTaint(evaluation.settingTaints, key, taint); + } +} + +function applyEvaluatedSetting( + evaluation: BuildSettingsEvaluation, + key: string, + value: string, + conditions: XCConfigCondition[] | undefined, +): void { + applySettings(evaluation.settings, { [key]: value }); + if (!INSPECTED_BUILD_SETTINGS.has(key)) return; + + // An unconditional literal assignment fully overrides any earlier unknown + // include for this setting. Inherited or variable-derived values may still + // depend on the skipped input, so retain their existing taint. + if ((conditions?.length ?? 0) === 0 && !hasBuildSettingVariable(value)) { + evaluation.settingTaints.delete(key); + } +} + +function hasBuildSettingVariable(value: string): boolean { + BUILD_SETTING_VARIABLE.lastIndex = 0; + const result = BUILD_SETTING_VARIABLE.test(value); + BUILD_SETTING_VARIABLE.lastIndex = 0; + return result; +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +function addDiagnosticOnce(diagnostics: IOSDiagnostic[], diagnostic: IOSDiagnostic): void { + if ( + diagnostics.some( + (candidate) => candidate.code === diagnostic.code && candidate.message === diagnostic.message, + ) + ) { + return; + } + diagnostics.push(diagnostic); +} + +function parseXCConfigOperations(content: string): XCConfigOperation[] { + const operations: XCConfigOperation[] = []; + // @bacons/xcode intentionally exposes includes and assignments separately. + // Parsing one source line at a time retains their interleaving, which is + // significant because a later include can override an earlier assignment. + for (const line of content.split(/\r?\n/)) { + const parsed = parseXCConfig(line); + const include = parsed.includes[0]?.include; + if (include) { + operations.push({ + kind: "include", + path: include.path, + optional: include.optional, + }); + continue; + } + const setting = parsed.buildSettings[0]; + if (setting) { + operations.push({ + kind: "setting", + key: setting.key, + value: setting.value, + conditions: setting.conditions, + }); + } + } + return operations; +} + +function unresolvedXCConfig( + root: string, + path: string, + diagnostics: IOSDiagnostic[], + message: string, + taint: string, + inherited: BuildSettingsEvaluation, +): BuildSettingsEvaluation { + addDiagnosticOnce(diagnostics, { + code: "xcode.unresolved-build-setting", + severity: "warning", + message, + remedy: + "Check in the required xcconfig and use a literal include path before automating setup.", + evidence: [{ path: relativeIOSPath(root, path), keyPath: "include" }], + }); + const evaluation = cloneEvaluation(inherited); + taintInspectedSettings(evaluation, taint); + return evaluation; +} + +async function readXCConfigSettings( + root: string, + path: string, + configuration: string, + context: BuildContext, + diagnostics: IOSDiagnostic[], + inherited: BuildSettingsEvaluation = { settings: {}, settingTaints: new Map() }, + visited: Set = new Set(), + depth = 0, + optional = false, +): Promise { + if (depth >= MAX_XCCONFIG_DEPTH) { + const unresolved = unresolvedXCConfig( + root, + path, + diagnostics, + `Could not fully evaluate ${relativeIOSPath(root, path)} because xcconfig includes exceeded the inspection depth limit.`, + "xcconfig include depth", + inherited, + ); + return unresolved; + } + if (visited.has(path)) { + const unresolved = unresolvedXCConfig( + root, + path, + diagnostics, + `Could not fully evaluate ${relativeIOSPath(root, path)} because its xcconfig includes form a cycle.`, + "xcconfig include cycle", + inherited, + ); + return unresolved; + } + if (!(await pathIsSafelyWithinIOSRoot(root, path))) { + addDiagnosticOnce(diagnostics, { + code: "xcode.external-path", + severity: "warning", + message: `Skipped xcconfig outside the inspected project root: ${path}`, + evidence: [{ path }], + }); + const evaluation = cloneEvaluation(inherited); + // Even an optional include can exist outside the inspected root and + // override local values. Since we intentionally do not read it, its + // contribution remains unknown. + taintInspectedSettings(evaluation, "xcconfig outside project root"); + return evaluation; + } + + let content: string; + try { + const file = Bun.file(path); + if (!(await file.exists())) { + if (optional) { + return cloneEvaluation(inherited); + } + const unresolved = unresolvedXCConfig( + root, + path, + diagnostics, + `Required xcconfig include ${relativeIOSPath(root, path)} does not exist.`, + "required xcconfig include", + inherited, + ); + return unresolved; + } + if (file.size > 1_000_000) { + const unresolved = unresolvedXCConfig( + root, + path, + diagnostics, + `Could not evaluate ${relativeIOSPath(root, path)} because the xcconfig is too large to inspect safely.`, + "unreadable xcconfig include", + inherited, + ); + return unresolved; + } + content = await readFile(path, "utf8"); + } catch { + const unresolved = unresolvedXCConfig( + root, + path, + diagnostics, + `Could not read required xcconfig include ${relativeIOSPath(root, path)}.`, + "unreadable xcconfig include", + inherited, + ); + return unresolved; + } + + const nextVisited = new Set(visited); + nextVisited.add(path); + let evaluation = cloneEvaluation(inherited); + + for (const operation of parseXCConfigOperations(content)) { + if (operation.kind === "setting") { + if (!conditionsMatch(operation.conditions, configuration, context)) continue; + applyEvaluatedSetting(evaluation, operation.key, operation.value, operation.conditions); + continue; + } + // Paths containing build variables cannot be resolved safely without an + // Xcode build context. Leave those settings unresolved rather than guess. + if (hasBuildSettingVariable(operation.path)) { + taintInspectedSettings(evaluation, "variable xcconfig include path"); + addDiagnosticOnce(diagnostics, { + code: "xcode.unresolved-build-setting", + severity: "warning", + message: `${relativeIOSPath(root, path)} has an xcconfig include whose path contains build-setting variables.`, + remedy: "Use a literal checked-in include path before automating setup.", + evidence: [{ path: relativeIOSPath(root, path), keyPath: "include" }], + }); + continue; + } + const includePath = resolve(dirname(path), operation.path); + const included = await readXCConfigSettings( + root, + includePath, + configuration, + context, + diagnostics, + evaluation, + nextVisited, + depth + 1, + operation.optional, + ); + evaluation = included; + } + + return evaluation; +} + +function stripSurroundingQuotes(value: string): string { + const trimmed = value.trim(); + if ( + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function cloneSettingTaints(source: Map): Map { + return new Map([...source].map(([key, values]) => [key, [...values]])); +} + +function addSettingTaint(settingTaints: Map, key: string, taint: string): void { + settingTaints.set(key, unique([...(settingTaints.get(key) ?? []), taint])); +} + +function parseInlineBuildSettingKey( + rawKey: string, +): + | { key: string; conditions: XCConfigCondition[]; supported: true } + | { key: string; supported: false } { + const match = rawKey.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\[[^\]]+\])*)$/); + if (!match) return { key: rawKey, supported: false }; + const key = match[1]; + if (!key) return { key: rawKey, supported: false }; + const suffix = match[2] ?? ""; + if (!suffix) return { key, conditions: [], supported: true }; + + const conditions: XCConfigCondition[] = []; + let consumed = ""; + for (const conditionMatch of suffix.matchAll(/\[([a-zA-Z]+)=([^\]]+)\]/g)) { + consumed += conditionMatch[0]; + const rawType = conditionMatch[1]; + const value = conditionMatch[2]; + if (!rawType || !value) return { key, supported: false }; + const type = rawType.toLowerCase(); + if (!["sdk", "arch", "config"].includes(type)) { + return { key, supported: false }; + } + if (type === "sdk") conditions.push({ sdk: value }); + if (type === "arch") conditions.push({ arch: value }); + if (type === "config") conditions.push({ config: value }); + } + if (consumed !== suffix) return { key, supported: false }; + return { key, conditions, supported: true }; +} + +function applyInlineBuildSettings( + evaluation: BuildSettingsEvaluation, + rawSettings: Record, + configuration: string, + context: BuildContext, +): void { + const parsed = Object.entries(rawSettings).map(([rawKey, value], order) => ({ + parsedKey: parseInlineBuildSettingKey(rawKey), + value, + order, + })); + + // A PBX buildSettings value is a dictionary, not an ordered instruction + // stream. Apply unconditional values first, followed by increasingly + // specific matching conditions. This lets an SDK-qualified value override + // its base value regardless of serialization order. + parsed.sort((left, right) => { + const leftSpecificity = + left.parsedKey.supported && "conditions" in left.parsedKey + ? left.parsedKey.conditions.length + : Number.MAX_SAFE_INTEGER; + const rightSpecificity = + right.parsedKey.supported && "conditions" in right.parsedKey + ? right.parsedKey.conditions.length + : Number.MAX_SAFE_INTEGER; + return leftSpecificity - rightSpecificity || left.order - right.order; + }); + + for (const entry of parsed) { + if (!entry.parsedKey.supported) { + addSettingTaint( + evaluation.settingTaints, + entry.parsedKey.key, + "unsupported conditional build setting", + ); + continue; + } + if (!conditionsMatch(entry.parsedKey.conditions, configuration, context)) continue; + applyEvaluatedSetting(evaluation, entry.parsedKey.key, entry.value, entry.parsedKey.conditions); + } +} + +function resolveSetting( + key: string, + evaluation: BuildSettingsEvaluation, + builtins: Record, + evidence: IOSSourceEvidence, +): IOSValueResolution { + const settings = evaluation.settings; + const initial = settings[key]; + const taints = evaluation.settingTaints.get(key) ?? []; + if ((initial == null || initial.trim() === "") && taints.length === 0) { + return { state: "missing", evidence: [evidence] }; + } + + const missingVariables = new Set(taints); + const resolveValue = (raw: string, stack: Set): string => { + BUILD_SETTING_VARIABLE.lastIndex = 0; + return raw.replace(BUILD_SETTING_VARIABLE, (match, parenthesized, braced) => { + const variableWithModifier = String(parenthesized ?? braced); + // Xcode build-setting modifiers transform values (for example, + // `:rfc1034identifier`). Replacing only the base variable would silently + // produce a different value, so preserve the expression as unresolved. + if (variableWithModifier.includes(":")) { + missingVariables.add(variableWithModifier); + return match; + } + const variable = variableWithModifier.split(":", 1)[0] ?? variableWithModifier; + if (variable.toLowerCase() === "inherited") return ""; + if (stack.has(variable)) { + missingVariables.add(variable); + return `$(${variableWithModifier})`; + } + + const replacement = settings[variable] ?? builtins[variable]; + if (replacement == null) { + missingVariables.add(variable); + return `$(${variableWithModifier})`; + } + + const nextStack = new Set(stack); + nextStack.add(variable); + return resolveValue(replacement, nextStack); + }); + }; + + const raw = initial ?? ""; + const value = stripSurroundingQuotes(resolveValue(raw, new Set([key]))); + if (missingVariables.size > 0 || hasBuildSettingVariable(value)) { + return { + state: "unresolved", + raw, + missingVariables: [...missingVariables].sort(), + evidence: [evidence], + }; + } + + return { state: "resolved", value, evidence: [evidence] }; +} + +interface ConfigurationReference { + id: string; + object?: PbxObject; +} + +function configurationReferences( + listId: string | undefined, + objects: PbxObjects, + diagnostics: IOSDiagnostic[], + evidencePath: string, + owner: string, +): { references: ConfigurationReference[]; complete: boolean } { + if (!listId) { + addDiagnosticOnce(diagnostics, { + code: "xcode.dangling-reference", + severity: "error", + message: `${owner} has no XCConfigurationList reference.`, + remedy: "Repair the Xcode project build-configuration list before automating setup.", + evidence: [{ path: evidencePath, keyPath: "buildConfigurationList" }], + }); + return { references: [], complete: false }; + } + const list = objects[listId]; + if (list?.isa !== "XCConfigurationList") { + addDiagnosticOnce(diagnostics, { + code: "xcode.dangling-reference", + severity: "error", + message: `${owner} references a missing or invalid XCConfigurationList (${listId}).`, + remedy: "Repair the Xcode project build-configuration list before automating setup.", + evidence: [{ path: evidencePath, objectId: listId }], + }); + return { references: [], complete: false }; + } + + let complete = true; + const references = asStringArray(list.buildConfigurations).map((id) => { + const object = objects[id]; + if (object?.isa === "XCBuildConfiguration") return { id, object }; + complete = false; + addDiagnosticOnce(diagnostics, { + code: "xcode.dangling-reference", + severity: "error", + message: `${owner} references a missing or invalid XCBuildConfiguration (${id}).`, + remedy: + "Repair or remove the dangling build-configuration reference before automating setup.", + evidence: [{ path: evidencePath, objectId: listId, keyPath: "buildConfigurations" }], + }); + return { id }; + }); + return { references, complete }; +} + +async function settingsForConfiguration( + root: string, + projectPath: string, + projectDirectory: string, + groupRootDirectory: string, + configuration: PbxObject | undefined, + configurationName: string, + context: BuildContext, + objects: PbxObjects, + parents: Map, + diagnostics: IOSDiagnostic[], + inherited: BuildSettingsEvaluation = { + settings: {}, + settingTaints: new Map(), + }, +): Promise { + let evaluation = cloneEvaluation(inherited); + if (!configuration) return evaluation; + const baseReference = asString(configuration.baseConfigurationReference); + if (baseReference) { + const configPath = resolvePbxFilePath( + baseReference, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (configPath) { + if (hasBuildSettingVariable(configPath)) { + taintInspectedSettings(evaluation, "variable base xcconfig path"); + addDiagnosticOnce(diagnostics, { + code: "xcode.unresolved-build-setting", + severity: "warning", + message: "An XCBuildConfiguration base xcconfig path contains build-setting variables.", + remedy: "Use a literal checked-in base xcconfig path before automating setup.", + evidence: [ + { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: baseReference, + keyPath: "baseConfigurationReference", + }, + ], + }); + } else { + evaluation = await readXCConfigSettings( + root, + configPath, + configurationName, + context, + diagnostics, + evaluation, + ); + } + } else { + taintInspectedSettings(evaluation, "unresolved base xcconfig reference"); + addDiagnosticOnce(diagnostics, { + code: "xcode.dangling-reference", + severity: "error", + message: `Could not resolve an XCBuildConfiguration baseConfigurationReference (${baseReference}).`, + remedy: "Repair the base xcconfig file reference before automating setup.", + evidence: [ + { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: baseReference, + keyPath: "baseConfigurationReference", + }, + ], + }); + } + } + applyInlineBuildSettings( + evaluation, + asStringRecord(configuration.buildSettings), + configurationName, + context, + ); + return evaluation; +} + +export interface InspectedTargetConfiguration { + model: IOSBuildConfiguration; + settings: Record; + isIOS: boolean; +} + +interface EvaluatedBuildContext { + context: BuildContext; + evaluation: BuildSettingsEvaluation; + builtins: Record; +} + +function resolutionSignature(resolution: IOSValueResolution): string { + if (resolution.state === "resolved") return `resolved:${resolution.value}`; + if (resolution.state === "missing") return "missing"; + return `unresolved:${resolution.raw}:${resolution.missingVariables.join(",")}`; +} + +function resolutionDisplay(resolution: IOSValueResolution): string { + if (resolution.state === "resolved") return resolution.value; + return resolution.state === "missing" ? "" : ""; +} + +function resolveSettingAcrossContexts( + key: string, + contexts: EvaluatedBuildContext[], + evidence: IOSSourceEvidence, + targetName: string, + configurationName: string, + diagnostics: IOSDiagnostic[], +): IOSValueResolution { + const variants = contexts.map(({ context, evaluation, builtins }) => ({ + context, + resolution: resolveSetting(key, evaluation, builtins, evidence), + })); + const signatures = new Set(variants.map(({ resolution }) => resolutionSignature(resolution))); + if (signatures.size <= 1) + return variants[0]?.resolution ?? { state: "missing", evidence: [evidence] }; + + addDiagnosticOnce(diagnostics, { + code: "xcode.conflicting-build-setting", + severity: "warning", + message: `${targetName} ${configurationName} has different ${key} values by SDK: ${variants + .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) + .join(", ")}`, + remedy: "Make device and simulator values consistent or select the intended SDK explicitly.", + evidence: variants.flatMap(({ resolution }) => resolution.evidence), + }); + + return { + state: "unresolved", + raw: variants + .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) + .join("; "), + missingVariables: unique([ + "sdk-conditioned build setting", + ...variants.flatMap(({ resolution }) => + resolution.state === "unresolved" ? resolution.missingVariables : [], + ), + ]).sort(), + evidence: variants.flatMap(({ resolution }) => resolution.evidence), + }; +} + +function missingConfiguration( + root: string, + projectPath: string, + configurationId: string, +): InspectedTargetConfiguration { + const evidence: IOSSourceEvidence = { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: configurationId, + keyPath: "buildConfigurations", + }; + const missing: IOSValueResolution = { state: "missing", evidence: [evidence] }; + return { + model: { + name: `Unresolved (${configurationId})`, + bundleIdentifier: missing, + developmentTeam: missing, + entitlementsPath: missing, + deploymentTarget: missing, + }, + settings: {}, + // The product type identifies this as an application target, but the + // dangling configuration does not contain enough evidence to exclude iOS. + isIOS: true, + }; +} + +export async function inspectTargetBuildConfigurations(options: { + root: string; + projectPath: string; + groupRootDirectory: string; + projectObject: PbxObject; + targetId: string; + targetObject: PbxObject; + objects: PbxObjects; + parents: Map; + diagnostics: IOSDiagnostic[]; +}): Promise { + const { + root, + projectPath, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics, + } = options; + const projectDirectory = dirname(projectPath); + const pbxprojRelativePath = relativeIOSPath(root, resolve(projectPath, "project.pbxproj")); + const projectConfigurationReferences = configurationReferences( + asString(projectObject.buildConfigurationList), + objects, + diagnostics, + pbxprojRelativePath, + "PBXProject", + ); + const projectConfigsByName = new Map( + projectConfigurationReferences.references.flatMap(({ object }) => + object ? [[asString(object.name) ?? "", object] as const] : [], + ), + ); + const targetName = asString(targetObject.name) ?? "App"; + const targetConfigurationReferences = configurationReferences( + asString(targetObject.buildConfigurationList), + objects, + diagnostics, + pbxprojRelativePath, + `Target ${targetName}`, + ); + const inspected: InspectedTargetConfiguration[] = []; + + for (const targetReference of targetConfigurationReferences.references.sort((a, b) => + (asString(a.object?.name) ?? a.id).localeCompare(asString(b.object?.name) ?? b.id), + )) { + const targetConfig = targetReference.object; + if (!targetConfig) { + inspected.push(missingConfiguration(root, projectPath, targetReference.id)); + continue; + } + const name = asString(targetConfig.name) ?? "Unnamed"; + const evaluatedContexts: EvaluatedBuildContext[] = []; + for (const context of BUILD_CONTEXTS) { + const inherited: BuildSettingsEvaluation = { + settings: {}, + settingTaints: new Map(), + }; + const projectSettings = await settingsForConfiguration( + root, + projectPath, + projectDirectory, + groupRootDirectory, + projectConfigsByName.get(name), + name, + context, + objects, + parents, + diagnostics, + inherited, + ); + const evaluation = await settingsForConfiguration( + root, + projectPath, + projectDirectory, + groupRootDirectory, + targetConfig, + name, + context, + objects, + parents, + diagnostics, + projectSettings, + ); + // A dangling project configuration cannot be attributed to Debug or + // Release, so keep completeness unknown even when another layer has a + // literal override. This is structural evidence, not an ordered + // xcconfig include that a later assignment can supersede. + if (!projectConfigurationReferences.complete) { + taintInspectedSettings(evaluation, "incomplete project configuration list"); + } + + const productName = + evaluation.settings.PRODUCT_NAME ?? asString(targetObject.productName) ?? targetName; + evaluatedContexts.push({ + context, + evaluation, + builtins: { + SRCROOT: projectDirectory, + PROJECT_DIR: projectDirectory, + PROJECT_NAME: + projectPath + .split(sep) + .at(-1) + ?.replace(/\.xcodeproj$/, "") ?? targetName, + TARGET_NAME: targetName, + PRODUCT_NAME: productName, + CONFIGURATION: name, + }, + }); + } + + const deviceContext = evaluatedContexts[0]; + if (!deviceContext) continue; + const evidence = (setting: string): IOSSourceEvidence => ({ + path: pbxprojRelativePath, + objectId: targetId, + keyPath: `buildConfigurations.${name}.buildSettings.${setting}`, + }); + const supportedPlatformsResolution = resolveSettingAcrossContexts( + "SUPPORTED_PLATFORMS", + evaluatedContexts, + evidence("SUPPORTED_PLATFORMS"), + targetName, + name, + diagnostics, + ); + const supportedPlatforms = + supportedPlatformsResolution.state === "resolved" ? supportedPlatformsResolution.value : ""; + const activeContexts = + supportedPlatformsResolution.state === "resolved" && + supportedPlatforms.trim() !== "" && + !/\biphonesimulator\b/.test(supportedPlatforms) + ? [deviceContext] + : evaluatedContexts; + const sdkRootResolution = resolveSettingAcrossContexts( + "SDKROOT", + activeContexts, + evidence("SDKROOT"), + targetName, + name, + diagnostics, + ); + const deploymentTarget = resolveSettingAcrossContexts( + "IPHONEOS_DEPLOYMENT_TARGET", + activeContexts, + evidence("IPHONEOS_DEPLOYMENT_TARGET"), + targetName, + name, + diagnostics, + ); + const sdkRoot = sdkRootResolution.state === "resolved" ? sdkRootResolution.value : ""; + const hasIOSSDK = sdkRootResolution.state === "resolved" && sdkRoot.includes("iphoneos"); + const hasIOSPlatform = + supportedPlatformsResolution.state === "resolved" && + /iphone(?:os|simulator)/.test(supportedPlatforms); + const hasPositiveIOSEvidence = + deploymentTarget.state !== "missing" || hasIOSSDK || hasIOSPlatform; + const hasUnknownPlatformEvidence = + sdkRootResolution.state === "unresolved" || + supportedPlatformsResolution.state === "unresolved"; + const hasResolvedNonIOSEvidence = + (sdkRootResolution.state === "resolved" && sdkRoot !== "" && !hasIOSSDK) || + (supportedPlatformsResolution.state === "resolved" && + supportedPlatforms !== "" && + !hasIOSPlatform); + const explicitlyNonIOS = + !hasPositiveIOSEvidence && !hasUnknownPlatformEvidence && hasResolvedNonIOSEvidence; + + const model: IOSBuildConfiguration = { + name, + bundleIdentifier: resolveSettingAcrossContexts( + "PRODUCT_BUNDLE_IDENTIFIER", + activeContexts, + evidence("PRODUCT_BUNDLE_IDENTIFIER"), + targetName, + name, + diagnostics, + ), + developmentTeam: resolveSettingAcrossContexts( + "DEVELOPMENT_TEAM", + activeContexts, + evidence("DEVELOPMENT_TEAM"), + targetName, + name, + diagnostics, + ), + entitlementsPath: resolveSettingAcrossContexts( + "CODE_SIGN_ENTITLEMENTS", + activeContexts, + evidence("CODE_SIGN_ENTITLEMENTS"), + targetName, + name, + diagnostics, + ), + deploymentTarget, + }; + const relevantSettings: Array<[string, IOSValueResolution]> = [ + ["PRODUCT_BUNDLE_IDENTIFIER", model.bundleIdentifier], + ["DEVELOPMENT_TEAM", model.developmentTeam], + ["CODE_SIGN_ENTITLEMENTS", model.entitlementsPath], + ["IPHONEOS_DEPLOYMENT_TARGET", model.deploymentTarget], + ["SDKROOT", sdkRootResolution], + ["SUPPORTED_PLATFORMS", supportedPlatformsResolution], + ]; + for (const [setting, resolution] of relevantSettings) { + if (resolution.state !== "unresolved") continue; + addDiagnosticOnce(diagnostics, { + code: "xcode.unresolved-build-setting", + severity: "warning", + message: `${targetName} ${name} has an unresolved ${setting} value (${resolution.missingVariables.join(", ") || "unknown variable"}).`, + remedy: "Make the setting resolvable from the project or its checked-in xcconfig files.", + evidence: resolution.evidence, + }); + } + + inspected.push({ + model, + settings: deviceContext.evaluation.settings, + isIOS: !explicitlyNonIOS, + }); + } + + return inspected; +} + +export function addBuildSettingConflictDiagnostics( + targetName: string, + configurations: IOSBuildConfiguration[], + diagnostics: IOSDiagnostic[], +): void { + const checks: Array<[string, (config: IOSBuildConfiguration) => IOSValueResolution]> = [ + ["PRODUCT_BUNDLE_IDENTIFIER", (config) => config.bundleIdentifier], + ["DEVELOPMENT_TEAM", (config) => config.developmentTeam], + ["CODE_SIGN_ENTITLEMENTS", (config) => config.entitlementsPath], + ]; + + for (const [setting, select] of checks) { + const values = configurations.map((configuration) => ({ + name: configuration.name, + resolution: select(configuration), + })); + const signatures = new Set( + values.map(({ resolution }) => + resolution.state === "resolved" + ? `resolved:${resolution.value}` + : resolution.state === "unresolved" + ? `unresolved:${resolution.raw}` + : "missing", + ), + ); + if (signatures.size <= 1) continue; + + diagnostics.push({ + code: "xcode.conflicting-build-setting", + severity: "warning", + message: `${targetName} has different ${setting} values across build configurations: ${values + .map( + ({ name, resolution }) => + `${name}=${ + resolution.state === "resolved" + ? resolution.value + : resolution.state === "unresolved" + ? "" + : "" + }`, + ) + .join(", ")}`, + remedy: "Select one configuration or make the values consistent before automating setup.", + evidence: values.flatMap((item) => item.resolution.evidence), + }); + } +} + +export function resolveEntitlementsAbsolutePath( + root: string, + projectPath: string, + resolution: IOSValueResolution, +): string | undefined { + if (resolution.state !== "resolved") return undefined; + const path = resolution.value; + const absolute = resolve(dirname(projectPath), path); + return pathIsWithinIOSRoot(root, absolute) ? absolute : undefined; +} diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts new file mode 100644 index 000000000..0b83ceb38 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -0,0 +1,330 @@ +import { readdir, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { IOSWorkspaceInspection } from "./types.ts"; + +const MAX_DISCOVERY_DEPTH = 3; +const MAX_EXHAUSTIVE_DISCOVERY_DEPTH = 24; +const MAX_DISCOVERY_DIRECTORIES = 10_000; +const MAX_DISCOVERED_CONTAINERS = 1_000; +const IGNORED_DIRECTORIES = new Set([ + ".build", + ".git", + ".swiftpm", + "build", + "Carthage", + "DerivedData", + "node_modules", + "Pods", + "SourcePackages", +]); + +export interface IOSContainerDiscovery { + projectPaths: string[]; + workspacePaths: string[]; + /** False when a bounded or unreadable traversal could have hidden another container. */ + complete: boolean; +} + +export interface IOSContainerDiscoveryOptions { + /** + * Traverse deeply enough for strict cross-target source-ownership proofs. + * Any traversal limit or read failure is returned as incomplete so callers + * can fail closed instead of treating a partial inventory as exhaustive. + */ + exhaustive?: boolean; +} + +interface ContainerWalkState { + complete: boolean; + directoriesVisited: number; + maxDepth: number; + includeHiddenDirectories: boolean; +} + +function isWithinRoot(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +export function relativeIOSPath(root: string, path: string): string { + const rel = relative(root, path); + return rel === "" ? "." : rel.split(sep).join("/"); +} + +/** + * Checks both the lexical path and every existing filesystem ancestor. This + * prevents an in-root symlink from turning a supposedly local read into a read + * somewhere else on disk. Non-existent leaf paths are allowed only when their + * nearest existing ancestor is still within the real root. + */ +export async function pathIsSafelyWithinIOSRoot( + rootInput: string, + candidateInput: string, +): Promise { + const root = resolve(rootInput); + const candidate = resolve(candidateInput); + if (!isWithinRoot(root, candidate)) return false; + + let realRoot: string; + try { + realRoot = await realpath(root); + } catch { + return false; + } + + let existingAncestor = candidate; + const missingSegments: string[] = []; + let realAncestor: string | undefined; + while (isWithinRoot(root, existingAncestor)) { + try { + realAncestor = await realpath(existingAncestor); + break; + } catch { + if (existingAncestor === root) break; + missingSegments.unshift(basename(existingAncestor)); + const parent = dirname(existingAncestor); + if (parent === existingAncestor) break; + existingAncestor = parent; + } + } + + if (!realAncestor) return false; + return isWithinRoot(realRoot, resolve(realAncestor, ...missingSegments)); +} + +async function walkContainers( + directory: string, + depth: number, + projects: Set, + workspaces: Set, + state: ContainerWalkState, +): Promise { + if (state.directoriesVisited >= MAX_DISCOVERY_DIRECTORIES) { + state.complete = false; + return; + } + state.directoriesVisited += 1; + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + state.complete = false; + return; + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const absolutePath = resolve(directory, entry.name); + + if (entry.name.endsWith(".xcodeproj")) { + if (projects.size + workspaces.size >= MAX_DISCOVERED_CONTAINERS) { + state.complete = false; + continue; + } + projects.add(absolutePath); + continue; + } + + if (entry.name.endsWith(".xcworkspace")) { + // Xcode creates a private workspace inside every .xcodeproj. It is an + // implementation detail, not a user-selectable workspace. + if (!directory.endsWith(".xcodeproj")) { + if (projects.size + workspaces.size >= MAX_DISCOVERED_CONTAINERS) { + state.complete = false; + continue; + } + workspaces.add(absolutePath); + } + continue; + } + + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + if (!state.includeHiddenDirectories && entry.name.startsWith(".")) continue; + if (depth >= state.maxDepth) { + state.complete = false; + continue; + } + await walkContainers(absolutePath, depth + 1, projects, workspaces, state); + } +} + +export async function discoverIOSContainers( + rootInput: string, + options: IOSContainerDiscoveryOptions = {}, +): Promise { + const root = resolve(rootInput); + const projects = new Set(); + const workspaces = new Set(); + const exhaustive = options.exhaustive === true; + const state: ContainerWalkState = { + complete: true, + directoriesVisited: 0, + maxDepth: exhaustive ? MAX_EXHAUSTIVE_DISCOVERY_DEPTH : MAX_DISCOVERY_DEPTH, + includeHiddenDirectories: exhaustive, + }; + + if (root.endsWith(".xcodeproj")) { + projects.add(root); + } else if (root.endsWith(".xcworkspace")) { + workspaces.add(root); + } else { + await walkContainers(root, 0, projects, workspaces, state); + } + + return { + projectPaths: [...projects].sort(), + workspacePaths: [...workspaces].sort(), + complete: state.complete, + }; +} + +function decodeXMLAttribute(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +export function xmlAttribute(source: string, name: string): string | undefined { + let cursor = 0; + while (cursor < source.length) { + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + const attributeName = /^[A-Za-z_:][A-Za-z0-9_.:-]*/.exec(source.slice(cursor))?.[0]; + if (!attributeName) { + cursor += 1; + continue; + } + cursor += attributeName.length; + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + if (source[cursor] !== "=") continue; + cursor += 1; + while (/\s/.test(source[cursor] ?? "")) cursor += 1; + const quote = source[cursor]; + if (quote !== '"' && quote !== "'") continue; + const valueStart = ++cursor; + const valueEnd = source.indexOf(quote, valueStart); + if (valueEnd === -1) return undefined; + if (attributeName === name) { + return decodeXMLAttribute(source.slice(valueStart, valueEnd)); + } + cursor = valueEnd + 1; + } + return undefined; +} + +function resolveWorkspaceLocation( + base: string, + location: string | undefined, + containerBase: string, +): string { + if (!location) return base; + const separatorIndex = location.indexOf(":"); + const scheme = separatorIndex === -1 ? "group" : location.slice(0, separatorIndex); + const rawPath = separatorIndex === -1 ? location : location.slice(separatorIndex + 1); + if (scheme === "absolute") return resolve(rawPath); + if (scheme === "container") return resolve(containerBase, rawPath); + return resolve(base, rawPath); +} + +/** + * Masks XML comments without joining the bytes on either side. Removing a + * comment outright could synthesize markup from two otherwise inert fragments + * (for example, `leRef>`). + */ +function maskXMLComments(source: string): string { + const chunks: string[] = []; + let cursor = 0; + while (cursor < source.length) { + const start = source.indexOf("", start + 4); + const end = closing === -1 ? source.length : closing + 3; + chunks.push(" ".repeat(end - start)); + cursor = end; + } + return chunks.join(""); +} + +/** + * Reads only project references from an Xcode workspace. We deliberately do + * not use Xcode or resolve packages. Unsupported/external references remain + * visible in the workspace inventory but are not traversed. + */ +export async function inspectWorkspace( + rootInput: string, + workspacePath: string, +): Promise<{ inspection: IOSWorkspaceInspection; localProjectPaths: string[] }> { + const root = resolve(rootInput); + const contentsPath = resolve(workspacePath, "contents.xcworkspacedata"); + let xml = ""; + try { + if (!(await pathIsSafelyWithinIOSRoot(root, contentsPath))) { + throw new Error("external workspace"); + } + const file = Bun.file(contentsPath); + if (!(await file.exists()) || file.size > 2_000_000) throw new Error("unreadable workspace"); + xml = maskXMLComments(await file.text()); + } catch { + return { + inspection: { path: relativeIOSPath(root, workspacePath), projectPaths: [] }, + localProjectPaths: [], + }; + } + + const projectPaths = new Set(); + const localProjectPaths = new Set(); + const workspaceDirectory = dirname(workspacePath); + const groupBases = [workspaceDirectory]; + const elementPattern = /<(\/)?(Workspace|Group|FileRef)\b([^>]*)>/g; + for (const match of xml.matchAll(elementPattern)) { + const closing = match[1] === "/"; + const tag = match[2]; + const attributes = match[3] ?? ""; + if (tag === "Group") { + if (closing) { + if (groupBases.length > 1) groupBases.pop(); + } else { + const base = groupBases.at(-1) ?? workspaceDirectory; + groupBases.push( + resolveWorkspaceLocation(base, xmlAttribute(attributes, "location"), workspaceDirectory), + ); + if (attributes.trimEnd().endsWith("/")) groupBases.pop(); + } + continue; + } + if (closing || tag !== "FileRef") continue; + + const location = xmlAttribute(attributes, "location"); + const base = groupBases.at(-1) ?? workspaceDirectory; + const embeddedProject = + location === "self:" && workspaceDirectory.endsWith(".xcodeproj") + ? workspaceDirectory + : undefined; + if (!embeddedProject && !location?.endsWith(".xcodeproj")) continue; + const absolutePath = + embeddedProject ?? resolveWorkspaceLocation(base, location, workspaceDirectory); + const safelyLocal = await pathIsSafelyWithinIOSRoot(root, absolutePath); + projectPaths.add(safelyLocal ? relativeIOSPath(root, absolutePath) : absolutePath); + if (safelyLocal) localProjectPaths.add(absolutePath); + } + + return { + inspection: { + path: relativeIOSPath(root, workspacePath), + projectPaths: [...projectPaths].sort(), + }, + localProjectPaths: [...localProjectPaths].sort(), + }; +} + +export function pathIsWithinIOSRoot(rootInput: string, candidate: string): boolean { + return isWithinRoot(resolve(rootInput), resolve(candidate)); +} diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts new file mode 100644 index 000000000..9af8b818a --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -0,0 +1,925 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { + applyIOSExistingFileTransaction, + applyIOSFileTransaction, + hashIOSFileBytes, + IOSFileTransactionError, + type IOSCreateFileMutation, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-file-transaction-")); + temporaryDirectories.push(root); + return root; +} + +async function mutation(path: string, candidate: string): Promise { + const originalBytes = new Uint8Array(await readFile(path)); + const candidateBytes = new TextEncoder().encode(candidate); + const info = await lstat(path); + return { + path, + originalBytes, + originalHash: hashIOSFileBytes(originalBytes), + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: info.mode & 0o7777, + }; +} + +async function createMutation( + path: string, + candidate: string, + mode = 0o644, +): Promise { + const candidateBytes = new TextEncoder().encode(candidate); + const parent = await lstat(dirname(path)); + return { + kind: "create", + path, + expectedParentIdentity: { device: parent.dev, inode: parent.ino }, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode, + }; +} + +async function expectNoTemporaryFiles(root: string): Promise { + expect((await readdir(root)).filter((name) => name.includes(".clerk-"))).toEqual([]); +} + +async function expectRecoverableClaimedOriginals( + root: string, + expectedContents: string[], +): Promise { + const transactionFiles = (await readdir(root)).filter((name) => name.includes(".clerk-")); + const claimedOriginals = transactionFiles.filter((name) => name.endsWith(".claimed")); + expect(transactionFiles.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect( + (await Promise.all(claimedOriginals.map((name) => readFile(join(root, name), "utf8")))).sort(), + ).toEqual([...expectedContents].sort()); +} + +async function waitForCondition(condition: () => boolean | Promise): Promise { + for (let attempt = 0; attempt < 5_000; attempt++) { + if (await condition()) return; + await Bun.sleep(1); + } + throw new Error("Timed out waiting for the file transaction test condition."); +} + +async function waitForStagingFile(root: string): Promise { + await waitForCondition(async () => + (await readdir(root)).some((name) => name.includes(".clerk-")), + ); +} + +function errorText(value: unknown, seen = new Set()): string { + if (value == null || seen.has(value)) return ""; + if (typeof value !== "object") return String(value); + seen.add(value); + if (value instanceof AggregateError) { + return `${value.name}: ${value.message}\n${value.errors + .map((error) => errorText(error, seen)) + .join("\n")}`; + } + if (value instanceof Error) { + return `${value.name}: ${value.message}\n${errorText(value.cause, seen)}`; + } + return ""; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS existing-file transaction", () => { + test("stages and commits every file sequentially while preserving modes", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "project.pbxproj"); + const secondPath = join(root, "App.swift"); + await writeFile(firstPath, "original project\n"); + await writeFile(secondPath, "original source\n"); + await chmod(firstPath, 0o640); + await chmod(secondPath, 0o600); + const mutations = await Promise.all([ + mutation(firstPath, "candidate project\n"), + mutation(secondPath, "candidate source\n"), + ]); + let firstValidated = false; + let secondValidated = false; + + const result = await applyIOSExistingFileTransaction(mutations, [ + async () => { + await Promise.resolve(); + firstValidated = true; + return (await readFile(firstPath, "utf8")) === "candidate project\n"; + }, + async () => { + await Promise.resolve(); + secondValidated = true; + return (await readFile(secondPath, "utf8")) === "candidate source\n"; + }, + ]); + + expect(result).toEqual({ status: "applied" }); + expect(firstValidated).toBe(true); + expect(secondValidated).toBe(true); + expect(await readFile(firstPath, "utf8")).toBe("candidate project\n"); + expect(await readFile(secondPath, "utf8")).toBe("candidate source\n"); + expect((await lstat(firstPath)).mode & 0o7777).toBe(0o640); + expect((await lstat(secondPath)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("keeps the public destination present and both crash states recoverable", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n"); + let backupPath: string | undefined; + const observed: string[] = []; + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { + beforeExistingDestinationBackup: async (destinationPath) => { + expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); + observed.push("before-backup"); + }, + afterExistingDestinationBackup: async (destinationPath, createdBackupPath) => { + backupPath = createdBackupPath; + expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); + expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); + const stagedPath = (await readdir(root)).find((name) => name.endsWith(".tmp")); + if (!stagedPath) throw new Error("expected the staged candidate to remain recoverable"); + expect(await readFile(join(root, stagedPath), "utf8")).toBe("candidate source\n"); + observed.push("backup-before-rename"); + }, + beforeExistingDestinationReplace: async (destinationPath, createdBackupPath) => { + expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); + expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); + observed.push("immediately-before-rename"); + }, + afterExistingDestinationReplace: async (destinationPath, createdBackupPath) => { + expect(await readFile(destinationPath, "utf8")).toBe("candidate source\n"); + expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); + expect((await readdir(root)).filter((name) => name.endsWith(".tmp"))).toEqual([]); + observed.push("candidate-and-backup-after-rename"); + }, + }); + + expect(result).toEqual({ status: "applied" }); + expect(observed).toEqual([ + "before-backup", + "backup-before-rename", + "immediately-before-rename", + "candidate-and-backup-after-rename", + ]); + expect(backupPath).toBeDefined(); + await expect(lstat(backupPath!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path, "utf8")).toBe("candidate source\n"); + await expectNoTemporaryFiles(root); + }); + + test("atomically restores the verified backup without an absent destination window", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n"); + let backupPath: string | undefined; + + const result = await applyIOSExistingFileTransaction([prepared], [async () => false], { + afterExistingDestinationBackup: (_destinationPath, createdBackupPath) => { + backupPath = createdBackupPath; + }, + beforeRollbackDestinationReplace: async (destinationPath, originalSourcePath) => { + expect(await readFile(destinationPath, "utf8")).toBe("candidate source\n"); + expect(backupPath).toBe(originalSourcePath); + expect(await readFile(originalSourcePath, "utf8")).toBe("original source\n"); + }, + afterRollbackDestinationReplace: async (destinationPath) => { + expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); + expect(backupPath).toBeDefined(); + await expect(lstat(backupPath!)).rejects.toMatchObject({ code: "ENOENT" }); + }, + }); + + expect(result).toEqual({ status: "rolled-back" }); + expect(await readFile(path, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(root); + }); + + test("rolls back an after-rename failure without exposing candidate source bytes", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + await writeFile(path, "original source\n"); + const prepared = await mutation(path, sensitiveCandidate); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], [async () => true], { + afterExistingDestinationReplace: () => { + throw new Error("forced after-rename failure"); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("commit-failed"); + expect(errorText(caught)).not.toContain(sensitiveCandidate); + expect(await readFile(path, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(root); + }); + + test("runs every postcondition and rolls back every file byte-for-byte", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "project.pbxproj"); + const secondPath = join(root, "App.swift"); + const originalProject = new Uint8Array([0, 1, 2, 255]); + const originalSource = new TextEncoder().encode("// original\r\n"); + await writeFile(firstPath, originalProject); + await writeFile(secondPath, originalSource); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + const mutations = await Promise.all([ + mutation(firstPath, `candidate ${sensitiveCandidate}`), + mutation(secondPath, `candidate ${sensitiveCandidate}`), + ]); + let throwingValidatorRan = false; + let falseValidatorRan = false; + + const result = await applyIOSExistingFileTransaction(mutations, [ + async () => { + throwingValidatorRan = true; + throw new Error(sensitiveCandidate); + }, + async () => { + falseValidatorRan = true; + return false; + }, + ]); + + expect(result).toEqual({ status: "rolled-back" }); + expect(JSON.stringify(result)).not.toContain(sensitiveCandidate); + expect(throwingValidatorRan).toBe(true); + expect(falseValidatorRan).toBe(true); + expect(new Uint8Array(await readFile(firstPath))).toEqual(originalProject); + expect(new Uint8Array(await readFile(secondPath))).toEqual(originalSource); + await expectNoTemporaryFiles(root); + }); + + test("rejects a candidate edit after an earlier postcondition validates it", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n"); + let releaseValidated: (() => void) | undefined; + const validated = new Promise((resolve) => { + releaseValidated = resolve; + }); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction( + [prepared], + [ + async () => { + const matches = (await readFile(path, "utf8")) === "candidate source\n"; + releaseValidated?.(); + return matches; + }, + async () => { + await validated; + await writeFile(path, "newer user source\n"); + return true; + }, + ], + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(path, "utf8")).toBe("newer user source\n"); + await expectRecoverableClaimedOriginals(root, ["original source\n"]); + }); + + test("cleans every staged candidate and preserves newer bytes when stale", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "project.pbxproj"); + const secondPath = join(root, "App.swift"); + await writeFile(firstPath, "original project\n"); + await writeFile(secondPath, "original source\n"); + const mutations = await Promise.all([ + mutation(firstPath, "candidate project\n"), + mutation(secondPath, "candidate source\n"), + ]); + await writeFile(firstPath, "newer user project\n"); + + const result = await applyIOSExistingFileTransaction(mutations, [async () => true]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(firstPath, "utf8")).toBe("newer user project\n"); + expect(await readFile(secondPath, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(root); + }); + + test("returns stale when the original mode changed after preparation", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + await chmod(path, 0o600); + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("original\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("returns stale when the original mode changes during staging", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "x".repeat(16 * 1024 * 1024)); + const changeMode = (async () => { + await waitForStagingFile(root); + await chmod(path, 0o600); + })(); + + const [result] = await Promise.all([ + applyIOSExistingFileTransaction([prepared], [async () => true]), + changeMode, + ]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("original\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("returns stale when a same-byte file replaces the original during staging", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + const replacementPath = join(root, "external-replacement.tmp"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "x".repeat(16 * 1024 * 1024)); + await writeFile(replacementPath, "original\n"); + await chmod(replacementPath, 0o640); + const replacementIdentity = await lstat(replacementPath); + const replaceOriginal = (async () => { + await waitForStagingFile(root); + await rename(replacementPath, path); + })(); + + const [result] = await Promise.all([ + applyIOSExistingFileTransaction([prepared], [async () => true]), + replaceOriginal, + ]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("original\n"); + expect((await lstat(path)).ino).toBe(replacementIdentity.ino); + await expectNoTemporaryFiles(root); + }); + + test("rechecks identity immediately before each replacement commit", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "First.swift"); + const targetPath = join(root, "Target.swift"); + const replacementPath = join(root, "external-target.tmp"); + await writeFile(firstPath, "original first\n"); + await writeFile(targetPath, "original target\n"); + await chmod(targetPath, 0o640); + const intermediatePaths = Array.from({ length: 20 }, (_, index) => + join(root, `Intermediate-${index}.swift`), + ); + await Promise.all(intermediatePaths.map((path) => writeFile(path, "original middle\n"))); + await writeFile(replacementPath, "original target\n"); + await chmod(replacementPath, 0o640); + const replacementIdentity = await lstat(replacementPath); + const prepared = await Promise.all([ + mutation(firstPath, "candidate first\n"), + ...intermediatePaths.map((path) => mutation(path, "candidate middle\n")), + mutation(targetPath, "candidate target\n"), + ]); + const replaceTarget = (async () => { + await waitForCondition(async () => { + try { + return (await readFile(firstPath, "utf8")) === "candidate first\n"; + } catch { + return false; + } + }); + await rename(replacementPath, targetPath); + })(); + + const [result] = await Promise.all([ + applyIOSExistingFileTransaction(prepared, [async () => true]), + replaceTarget, + ]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(firstPath, "utf8")).toBe("original first\n"); + for (const path of intermediatePaths) { + expect(await readFile(path, "utf8")).toBe("original middle\n"); + } + expect(await readFile(targetPath, "utf8")).toBe("original target\n"); + expect((await lstat(targetPath)).ino).toBe(replacementIdentity.ino); + await expectNoTemporaryFiles(root); + }); + + test("preserves a replacement visible before the final atomic commit check", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + const replacementPath = join(root, "external-replacement.tmp"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + await writeFile(replacementPath, "newer user source\n"); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { + beforeExistingDestinationReplace: async (destinationPath) => { + await rename(replacementPath, destinationPath); + }, + }); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("newer user source\n"); + expect((await lstat(path)).ino).toBe(replacementIdentity.ino); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("preserves writes through an already-open original file descriptor", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + const editor = await open(path, "r+"); + + let result; + try { + result = await applyIOSExistingFileTransaction( + [prepared], + [ + async () => { + await editor.truncate(0); + await editor.write("newer user source through open fd\n", 0, "utf8"); + await editor.chmod(0o600); + await editor.sync(); + return true; + }, + ], + ); + } finally { + await editor.close(); + } + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("newer user source through open fd\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("guards rollback with candidate hashes and reports an explicit failure", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "project.pbxproj"); + const secondPath = join(root, "App.swift"); + await writeFile(firstPath, "original project\n"); + await writeFile(secondPath, "original source\n"); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + const mutations = await Promise.all([ + mutation(firstPath, `candidate project ${sensitiveCandidate}\n`), + mutation(secondPath, `candidate source ${sensitiveCandidate}\n`), + ]); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction(mutations, [ + async () => { + await writeFile(secondPath, "newer user source\n"); + return false; + }, + ]); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(errorText(caught)).not.toContain(sensitiveCandidate); + expect(await readFile(firstPath, "utf8")).toBe(`candidate project ${sensitiveCandidate}\n`); + expect(await readFile(secondPath, "utf8")).toBe("newer user source\n"); + await expectRecoverableClaimedOriginals(root, ["original project\n", "original source\n"]); + }); + + test("preserves a candidate whose mode changed before rollback", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction( + [prepared], + [ + async () => { + await chmod(path, 0o600); + return false; + }, + ], + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(path, "utf8")).toBe("candidate\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectRecoverableClaimedOriginals(root, ["original\n"]); + }); + + test("preserves a same-byte file that replaced the committed candidate inode", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + const replacementPath = join(root, "external-replacement.tmp"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction( + [prepared], + [ + async () => { + await writeFile(replacementPath, "candidate\n"); + await chmod(replacementPath, 0o640); + await rename(replacementPath, path); + return false; + }, + ], + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(path, "utf8")).toBe("candidate\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o640); + await expectRecoverableClaimedOriginals(root, ["original\n"]); + }); + + test("preserves a replacement visible before the final atomic rollback check", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + const replacementPath = join(root, "external-replacement.tmp"); + await writeFile(path, "original\n"); + await chmod(path, 0o640); + const prepared = await mutation(path, "candidate\n"); + await writeFile(replacementPath, "newer user source\n"); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], [async () => false], { + beforeRollbackDestinationReplace: async (destinationPath) => { + await rename(replacementPath, destinationPath); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(path, "utf8")).toBe("newer user source\n"); + expect((await lstat(path)).ino).toBe(replacementIdentity.ino); + expect((await lstat(path)).mode & 0o7777).toBe(0o600); + await expectRecoverableClaimedOriginals(root, ["original\n"]); + }); + + test("rejects invalid prepared hashes without exposing candidate bytes", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original\n"); + const prepared = await mutation(path, "pk_test_candidate_must_not_escape"); + prepared.candidateHash = hashIOSFileBytes("different bytes"); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], []); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("invalid-mutation"); + expect(errorText(caught)).not.toContain("pk_test_candidate_must_not_escape"); + expect(await readFile(path, "utf8")).toBe("original\n"); + await expectNoTemporaryFiles(root); + }); +}); + +describe("iOS create-file transaction", () => { + test("returns stale when the prepared parent directory was replaced", async () => { + const root = await temporaryRoot(); + const synchronizedRoot = join(root, "CoolApp"); + const displacedRoot = join(root, "CoolApp-before-replacement"); + const createdPath = join(synchronizedRoot, "CoolApp.entitlements"); + await mkdir(synchronizedRoot); + const prepared = await createMutation(createdPath, "candidate entitlements\n"); + await rename(synchronizedRoot, displacedRoot); + await mkdir(synchronizedRoot); + + const result = await applyIOSFileTransaction([prepared], [async () => true]); + + expect(result).toEqual({ status: "stale" }); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(join(displacedRoot, "CoolApp.entitlements"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expectNoTemporaryFiles(synchronizedRoot); + await expectNoTemporaryFiles(displacedRoot); + }); + + test("fails closed when the prepared parent identity changes after create staging", async () => { + const root = await temporaryRoot(); + const synchronizedRoot = join(root, "CoolApp"); + const displacedRoot = join(root, "CoolApp-before-replacement"); + const createdPath = join(synchronizedRoot, "CoolApp.entitlements"); + const sourcePath = join(root, "CoolApp.swift"); + await mkdir(synchronizedRoot); + await writeFile(sourcePath, "original source\n"); + const prepared = [ + await createMutation(createdPath, "candidate entitlements\n"), + await mutation(sourcePath, "x".repeat(16 * 1024 * 1024)), + ]; + const replaceParentAfterCreateStage = (async () => { + await waitForStagingFile(root); + await rename(synchronizedRoot, displacedRoot); + await symlink(displacedRoot, synchronizedRoot, "dir"); + })(); + + const [result] = await Promise.all([ + applyIOSFileTransaction(prepared, [async () => true]), + replaceParentAfterCreateStage, + ]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(sourcePath, "utf8")).toBe("original source\n"); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expectNoTemporaryFiles(displacedRoot); + await expectNoTemporaryFiles(root); + }); + + test("restores an existing file when a create parent moves after its candidate is linked", async () => { + const root = await temporaryRoot(); + const synchronizedRoot = join(root, "CoolApp"); + const displacedRoot = join(root, "CoolApp-before-replacement"); + const createdPath = join(synchronizedRoot, "CoolApp.entitlements"); + const projectPath = join(root, "project.pbxproj"); + const concurrentPath = join(synchronizedRoot, "concurrent-user-file"); + await mkdir(synchronizedRoot); + await writeFile(concurrentPath, "newer directory state\n"); + await writeFile(projectPath, "original project\n"); + const prepared = [ + await mutation(projectPath, "candidate project\n"), + await createMutation(createdPath, "candidate entitlements\n"), + ]; + + let caught: unknown; + try { + await applyIOSFileTransaction(prepared, [async () => true], { + beforeExistingDestinationReplace: async () => { + await rename(synchronizedRoot, displacedRoot); + await mkdir(synchronizedRoot); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(projectPath, "utf8")).toBe("original project\n"); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(displacedRoot, "concurrent-user-file"), "utf8")).toBe( + "newer directory state\n", + ); + }); + + test("restores a committed project file if the create parent changes during postvalidation", async () => { + const root = await temporaryRoot(); + const synchronizedRoot = join(root, "CoolApp"); + const displacedRoot = join(root, "CoolApp-before-replacement"); + const createdPath = join(synchronizedRoot, "CoolApp.entitlements"); + const projectPath = join(root, "project.pbxproj"); + await mkdir(synchronizedRoot); + await writeFile(projectPath, "original project\n"); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + const prepared = [ + await createMutation(createdPath, sensitiveCandidate), + await mutation(projectPath, "candidate project\n"), + ]; + + let caught: unknown; + try { + await applyIOSFileTransaction(prepared, [ + async () => { + await rename(synchronizedRoot, displacedRoot); + await mkdir(synchronizedRoot); + return true; + }, + ]); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(errorText(caught)).not.toContain(sensitiveCandidate); + expect(await readFile(projectPath, "utf8")).toBe("original project\n"); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(join(displacedRoot, "CoolApp.entitlements"), "utf8")).toBe( + sensitiveCandidate, + ); + }); + + test("creates an absent file alongside a replacement and preserves both modes", async () => { + const root = await temporaryRoot(); + const existingPath = join(root, "project.pbxproj"); + const createdPath = join(root, "CoolApp.entitlements"); + await writeFile(existingPath, "original project\n"); + await chmod(existingPath, 0o600); + + const result = await applyIOSFileTransaction( + [ + await createMutation(createdPath, "created entitlements\n", 0o640), + await mutation(existingPath, "candidate project\n"), + ], + [ + async () => (await readFile(createdPath, "utf8")) === "created entitlements\n", + async () => (await readFile(existingPath, "utf8")) === "candidate project\n", + ], + ); + + expect(result).toEqual({ status: "applied" }); + expect(await readFile(createdPath, "utf8")).toBe("created entitlements\n"); + expect(await readFile(existingPath, "utf8")).toBe("candidate project\n"); + expect((await lstat(createdPath)).mode & 0o7777).toBe(0o640); + expect((await lstat(createdPath)).nlink).toBe(1); + expect((await lstat(existingPath)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + + test("treats a formerly absent path as stale without clobbering it", async () => { + const root = await temporaryRoot(); + const existingPath = join(root, "project.pbxproj"); + const createdPath = join(root, "CoolApp.entitlements"); + await writeFile(existingPath, "original project\n"); + const mutations = [ + await mutation(existingPath, "candidate project\n"), + await createMutation(createdPath, "candidate entitlements\n"), + ]; + await writeFile(createdPath, "newer user entitlements\n"); + + const result = await applyIOSFileTransaction(mutations, [async () => true]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(existingPath, "utf8")).toBe("original project\n"); + expect(await readFile(createdPath, "utf8")).toBe("newer user entitlements\n"); + await expectNoTemporaryFiles(root); + }); + + test("removes a created file and restores replacements when validation fails", async () => { + const root = await temporaryRoot(); + const existingPath = join(root, "project.pbxproj"); + const createdPath = join(root, "CoolApp.entitlements"); + await writeFile(existingPath, "original project\n"); + + const result = await applyIOSFileTransaction( + [ + await mutation(existingPath, "candidate project\n"), + await createMutation(createdPath, "candidate entitlements\n"), + ], + [async () => false], + ); + + expect(result).toEqual({ status: "rolled-back" }); + expect(await readFile(existingPath, "utf8")).toBe("original project\n"); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expectNoTemporaryFiles(root); + }); + + test("preserves newer bytes at a created path when rollback is requested", async () => { + const root = await temporaryRoot(); + const createdPath = join(root, "CoolApp.entitlements"); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + + let caught: unknown; + try { + await applyIOSFileTransaction( + [await createMutation(createdPath, sensitiveCandidate)], + [ + async () => { + await writeFile(createdPath, "newer user entitlements\n"); + return false; + }, + ], + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(errorText(caught)).not.toContain(sensitiveCandidate); + expect(await readFile(createdPath, "utf8")).toBe("newer user entitlements\n"); + await expectNoTemporaryFiles(root); + }); + + test("preserves a same-byte file that replaced the transaction's created inode", async () => { + const root = await temporaryRoot(); + const createdPath = join(root, "CoolApp.entitlements"); + const candidate = "candidate entitlements\n"; + + let caught: unknown; + try { + await applyIOSFileTransaction( + [await createMutation(createdPath, candidate)], + [ + async () => { + await rm(createdPath); + await writeFile(createdPath, candidate); + return false; + }, + ], + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("rollback-failed"); + expect(await readFile(createdPath, "utf8")).toBe(candidate); + await expectNoTemporaryFiles(root); + }); + + test("rejects an invalid create hash without exposing candidate bytes", async () => { + const root = await temporaryRoot(); + const createdPath = join(root, "CoolApp.entitlements"); + const sensitiveCandidate = "pk_test_candidate_must_not_escape"; + const prepared = await createMutation(createdPath, sensitiveCandidate); + prepared.candidateHash = hashIOSFileBytes("different bytes"); + + let caught: unknown; + try { + await applyIOSFileTransaction([prepared], []); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("invalid-mutation"); + expect(errorText(caught)).not.toContain(sensitiveCandidate); + await expect(lstat(createdPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expectNoTemporaryFiles(root); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts new file mode 100644 index 000000000..3aab961c8 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -0,0 +1,1163 @@ +import { link, lstat, open, readFile, rename, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { basename, dirname, isAbsolute, resolve } from "node:path"; + +/** + * An already-inspected existing file and its validated replacement bytes. + * + * @internal This contains candidate bytes and must never be included in CLI + * output, telemetry, serialized plans, or public errors/results. + */ +export interface IOSExistingFileMutation { + path: string; + originalBytes: Uint8Array; + originalHash: string; + candidateBytes: Uint8Array; + candidateHash: string; + mode: number; +} + +/** + * An already-inspected absent path and the validated bytes to create there. + * + * @internal This contains candidate bytes and must never be included in CLI + * output, telemetry, serialized plans, or public errors/results. + */ +export interface IOSCreateFileMutation { + kind: "create"; + path: string; + /** Exact parent directory inspected while preparing this create. */ + expectedParentIdentity: { device: number; inode: number }; + candidateBytes: Uint8Array; + candidateHash: string; + mode: number; +} + +export type IOSFileMutation = IOSExistingFileMutation | IOSCreateFileMutation; + +export type IOSFilePostcondition = () => boolean | Promise; + +export type IOSFileTransactionResult = + | { status: "applied" } + | { status: "stale" } + | { status: "rolled-back" }; + +export type IOSFileTransactionErrorCode = + | "invalid-mutation" + | "stage-failed" + | "cleanup-failed" + | "commit-failed" + | "rollback-failed"; + +/** A fixed-message failure that never carries mutation contents. */ +export class IOSFileTransactionError extends Error { + constructor( + readonly code: IOSFileTransactionErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "IOSFileTransactionError"; + } +} + +interface StagedMutation { + mutation: IOSFileMutation; + temporaryPath: string; + temporaryPresent: boolean; + stagedIdentity: FileIdentity; + committedIdentity?: FileIdentity; + claimedOriginal?: ClaimedDestination; +} + +interface ClaimedDestination { + path: string; + present: boolean; + identity: FileIdentity; +} + +interface FileIdentity { + dev: number; + ino: number; + mode: number; +} + +interface DirectoryIdentity { + device: number; + inode: number; +} + +class IOSFileTransactionStaleError extends Error {} + +class IOSFileTransactionOwnershipError extends Error {} + +/** + * Deterministic race hooks used only by the file-transaction regression tests. + * + * @internal + */ +export interface IOSFileTransactionTestHooks { + beforeExistingDestinationBackup?: (path: string) => void | Promise; + afterExistingDestinationBackup?: (path: string, backupPath: string) => void | Promise; + beforeExistingDestinationReplace?: (path: string, backupPath: string) => void | Promise; + afterExistingDestinationReplace?: (path: string, backupPath: string) => void | Promise; + beforeRollbackDestinationReplace?: (path: string, backupPath: string) => void | Promise; + afterRollbackDestinationReplace?: (path: string) => void | Promise; +} + +export function hashIOSFileBytes(value: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +} + +function transactionError( + code: IOSFileTransactionErrorCode, + message: string, + cause?: unknown, +): IOSFileTransactionError { + return new IOSFileTransactionError(code, message, cause === undefined ? undefined : { cause }); +} + +function aggregateCause(errors: unknown[]): unknown { + return errors.length === 1 ? errors[0] : new AggregateError(errors); +} + +async function syncDirectory(path: string): Promise { + try { + const directory = await open(path, "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } catch { + // Same-directory rename remains atomic even when directory fsync is not + // available on the current filesystem. + } +} + +async function fileMatchesHash(path: string, expectedHash: string): Promise { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) return false; + return hashIOSFileBytes(await readFile(path)) === expectedHash; + } catch { + return false; + } +} + +async function readRegularFileIdentity(path: string): Promise { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink()) return undefined; + return { dev: info.dev, ino: info.ino, mode: info.mode & 0o7777 }; + } catch { + return undefined; + } +} + +async function readPathIdentity(path: string): Promise { + try { + const info = await lstat(path); + return { dev: info.dev, ino: info.ino, mode: info.mode & 0o7777 }; + } catch { + return undefined; + } +} + +async function readDirectoryIdentity(path: string): Promise { + try { + const info = await lstat(path); + if (!info.isDirectory() || info.isSymbolicLink()) return undefined; + return { device: info.dev, inode: info.ino }; + } catch { + return undefined; + } +} + +function identitiesMatch(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode; +} + +function sameFile(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +async function createParentStillMatches(mutation: IOSCreateFileMutation): Promise { + const current = await readDirectoryIdentity(dirname(mutation.path)); + return ( + current !== undefined && + current.device === mutation.expectedParentIdentity.device && + current.inode === mutation.expectedParentIdentity.inode + ); +} + +async function createParentsStillMatch(mutations: readonly IOSFileMutation[]): Promise { + const matches = await Promise.all( + mutations.filter(isCreateMutation).map(async (mutation) => createParentStillMatches(mutation)), + ); + return matches.every(Boolean); +} + +async function fileMatchesIdentityAndHash( + path: string, + expectedIdentity: FileIdentity, + expectedHash: string, +): Promise { + try { + const beforeRead = await readRegularFileIdentity(path); + if (!beforeRead || !identitiesMatch(beforeRead, expectedIdentity)) return false; + const matchesHash = hashIOSFileBytes(await readFile(path)) === expectedHash; + const afterRead = await readRegularFileIdentity(path); + return matchesHash && afterRead !== undefined && identitiesMatch(afterRead, expectedIdentity); + } catch { + return false; + } +} + +async function pathIsAbsent(path: string): Promise { + try { + await lstat(path); + return false; + } catch (error) { + return isFileSystemError(error, "ENOENT"); + } +} + +function isFileSystemError(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function transactionSiblingPath(path: string, purpose: string): string { + return resolve( + dirname(path), + `.${basename(path)}.clerk-${process.pid}-${randomUUID()}.${purpose}`, + ); +} + +async function removeClaimedPath( + claim: ClaimedDestination, + options: { expectedHash?: string; expectedMode?: number } = {}, +): Promise { + const currentIdentity = await readPathIdentity(claim.path); + if ( + !claim.present || + !currentIdentity || + !sameFile(currentIdentity, claim.identity) || + (options.expectedMode !== undefined && currentIdentity.mode !== options.expectedMode) || + (options.expectedHash !== undefined && + !(await fileMatchesIdentityAndHash(claim.path, currentIdentity, options.expectedHash))) + ) { + throw new IOSFileTransactionOwnershipError( + "a claimed path no longer identified the transaction's file", + ); + } + await rm(claim.path); + claim.present = false; +} + +async function restoreClaimWithoutClobber( + claim: ClaimedDestination, + destinationPath: string, +): Promise { + try { + await link(claim.path, destinationPath); + } catch (error) { + if (isFileSystemError(error, "EEXIST")) { + const destinationIdentity = await readPathIdentity(destinationPath); + if (destinationIdentity && sameFile(destinationIdentity, claim.identity)) { + await removeClaimedPath(claim); + await syncDirectory(dirname(destinationPath)); + return; + } + } + throw new IOSFileTransactionOwnershipError( + "a claimed destination could not be restored without overwriting newer filesystem state", + { cause: error }, + ); + } + + const destinationIdentity = await readPathIdentity(destinationPath); + const claimIdentity = await readPathIdentity(claim.path); + if ( + !destinationIdentity || + !claimIdentity || + !sameFile(destinationIdentity, claim.identity) || + !sameFile(claimIdentity, claim.identity) + ) { + throw new IOSFileTransactionOwnershipError( + "a restored destination no longer identified the transaction's claimed file", + ); + } + await removeClaimedPath(claim); + await syncDirectory(dirname(destinationPath)); +} + +type ClaimDestinationResult = + | { status: "claimed"; claim: ClaimedDestination } + | { status: "stale" }; + +/** + * Hard-links the authorized destination to a unique same-directory recovery + * path without ever removing the public destination. The backup remains until + * aggregate postvalidation succeeds or rollback atomically restores it. + */ +async function backupDestination( + destinationPath: string, + expectedIdentity: FileIdentity, + expectedHash: string, +): Promise { + const backupPath = transactionSiblingPath(destinationPath, "claimed"); + try { + await link(destinationPath, backupPath); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; + throw error; + } + + const linkedIdentity = await readPathIdentity(backupPath); + if (!linkedIdentity) return { status: "stale" }; + const backup: ClaimedDestination = { + path: backupPath, + present: true, + identity: linkedIdentity, + }; + const backupIdentity = await readRegularFileIdentity(backupPath); + if (!backupIdentity) { + await removeClaimedPath(backup); + return { status: "stale" }; + } + const backupMatches = + identitiesMatch(backupIdentity, expectedIdentity) && + (await fileMatchesIdentityAndHash(backupPath, expectedIdentity, expectedHash)); + const destinationMatches = await fileMatchesIdentityAndHash( + destinationPath, + expectedIdentity, + expectedHash, + ); + if (backupMatches && destinationMatches) return { status: "claimed", claim: backup }; + + await removeClaimedPath(backup); + return { status: "stale" }; +} + +/** + * Moves an existing destination to a unique same-directory name, then proves + * which inode was moved. Unlike an overwriting rename, this never destroys a + * replacement that arrives after the caller's last stale-input check. + */ +async function claimDestination( + destinationPath: string, + expectedIdentity: FileIdentity, + expectedHash: string, +): Promise { + const claimedPath = transactionSiblingPath(destinationPath, "claimed"); + try { + await rename(destinationPath, claimedPath); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; + throw error; + } + + const movedIdentity = await readPathIdentity(claimedPath); + if (!movedIdentity) { + throw new IOSFileTransactionOwnershipError( + "a claimed destination could not be identified after it was moved", + ); + } + const claim: ClaimedDestination = { + path: claimedPath, + present: true, + identity: movedIdentity, + }; + const movedExpectedFile = + identitiesMatch(movedIdentity, expectedIdentity) && + (await fileMatchesIdentityAndHash(claimedPath, expectedIdentity, expectedHash)); + if (movedExpectedFile) return { status: "claimed", claim }; + + await restoreClaimWithoutClobber(claim, destinationPath); + return { status: "stale" }; +} + +function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { + return "kind" in mutation && mutation.kind === "create"; +} + +function validateMutations(mutations: readonly IOSFileMutation[]): void { + if (mutations.length === 0) { + throw transactionError( + "invalid-mutation", + "The iOS file transaction did not contain a file mutation.", + ); + } + + const paths = new Set(); + for (const mutation of mutations) { + if ( + !mutation.path || + paths.has(mutation.path) || + !Number.isInteger(mutation.mode) || + mutation.mode < 0 || + mutation.mode > 0o7777 || + hashIOSFileBytes(mutation.candidateBytes) !== mutation.candidateHash || + (isCreateMutation(mutation) && + (!Number.isSafeInteger(mutation.expectedParentIdentity?.device) || + mutation.expectedParentIdentity.device < 0 || + !Number.isSafeInteger(mutation.expectedParentIdentity.inode) || + mutation.expectedParentIdentity.inode < 0)) || + (!isCreateMutation(mutation) && + hashIOSFileBytes(mutation.originalBytes) !== mutation.originalHash) + ) { + throw transactionError( + "invalid-mutation", + "The iOS file transaction contained an invalid or duplicate mutation.", + ); + } + paths.add(mutation.path); + } +} + +function snapshotMutations(mutations: readonly IOSFileMutation[]): IOSFileMutation[] { + try { + return mutations.map((mutation) => { + if (!mutation.path || !isAbsolute(mutation.path)) { + throw new Error("mutation path must be absolute"); + } + if (isCreateMutation(mutation)) { + return { + kind: "create", + path: resolve(mutation.path), + expectedParentIdentity: { ...mutation.expectedParentIdentity }, + candidateBytes: new Uint8Array(mutation.candidateBytes), + candidateHash: mutation.candidateHash, + mode: mutation.mode, + }; + } + return { + path: resolve(mutation.path), + originalBytes: new Uint8Array(mutation.originalBytes), + originalHash: mutation.originalHash, + candidateBytes: new Uint8Array(mutation.candidateBytes), + candidateHash: mutation.candidateHash, + mode: mutation.mode, + }; + }); + } catch (error) { + throw transactionError( + "invalid-mutation", + "The iOS file transaction contained an invalid mutation.", + error, + ); + } +} + +async function stageBytes( + mutation: IOSFileMutation, + bytes: Uint8Array, + expectedHash: string, +): Promise { + const temporaryPath = resolve( + dirname(mutation.path), + `.${basename(mutation.path)}.clerk-${process.pid}-${randomUUID()}.tmp`, + ); + let created = false; + let openedIdentity: FileIdentity | undefined; + try { + if (isCreateMutation(mutation) && !(await createParentStillMatches(mutation))) { + throw new IOSFileTransactionStaleError(); + } + const temporary = await open(temporaryPath, "wx", mutation.mode); + created = true; + try { + const info = await temporary.stat(); + if (!info.isFile()) throw new Error("staged path was not a regular file"); + openedIdentity = { + dev: info.dev, + ino: info.ino, + mode: info.mode & 0o7777, + }; + await temporary.writeFile(bytes); + await temporary.chmod(mutation.mode); + await temporary.sync(); + } finally { + await temporary.close(); + } + if (!(await fileMatchesHash(temporaryPath, expectedHash))) { + throw new Error("staged bytes did not match their prepared hash"); + } + const stagedIdentity = await readRegularFileIdentity(temporaryPath); + if (!stagedIdentity || stagedIdentity.mode !== mutation.mode) { + throw new Error("staged file identity did not match its prepared mode"); + } + if (isCreateMutation(mutation) && !(await createParentStillMatches(mutation))) { + throw new IOSFileTransactionStaleError(); + } + return { mutation, temporaryPath, temporaryPresent: true, stagedIdentity }; + } catch (error) { + if (created) { + try { + const currentIdentity = await readRegularFileIdentity(temporaryPath); + if (!openedIdentity || !currentIdentity || !sameFile(currentIdentity, openedIdentity)) { + throw new Error("the staged path no longer identified the transaction's file"); + } + await rm(temporaryPath); + } catch (cleanupError) { + throw transactionError( + "cleanup-failed", + "A staged iOS file could not be removed after staging failed.", + aggregateCause([error, cleanupError]), + ); + } + } + if (error instanceof IOSFileTransactionStaleError) throw error; + throw transactionError( + "stage-failed", + "The iOS file transaction could not be staged safely.", + error, + ); + } +} + +async function cleanupStaged(staged: readonly StagedMutation[]): Promise { + const results = await Promise.allSettled( + staged + .filter((item) => item.temporaryPresent) + .map(async (item) => { + const currentIdentity = await readRegularFileIdentity(item.temporaryPath); + if (!currentIdentity || !sameFile(currentIdentity, item.stagedIdentity)) { + throw new Error("the staged path no longer identified the transaction's file"); + } + await rm(item.temporaryPath); + item.temporaryPresent = false; + }), + ); + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (failures.length > 0) { + throw transactionError( + "cleanup-failed", + "One or more staged iOS files could not be removed.", + aggregateCause(failures), + ); + } +} + +async function stageAll( + mutations: readonly IOSFileMutation[], + content: (mutation: IOSFileMutation) => { bytes: Uint8Array; hash: string }, +): Promise { + const staged: StagedMutation[] = []; + try { + for (const mutation of mutations) { + const value = content(mutation); + staged.push(await stageBytes(mutation, value.bytes, value.hash)); + } + return staged; + } catch (error) { + try { + await cleanupStaged(staged); + } catch (cleanupError) { + throw transactionError( + "cleanup-failed", + "The iOS file transaction failed to clean up after staging.", + aggregateCause([error, cleanupError]), + ); + } + throw error; + } +} + +async function captureInitialExistingFileIdentities( + mutations: readonly IOSFileMutation[], +): Promise | undefined> { + const identities = new Map(); + const states = await Promise.all( + mutations.map(async (mutation) => { + if (isCreateMutation(mutation)) { + return (await createParentStillMatches(mutation)) && (await pathIsAbsent(mutation.path)); + } + const identity = await readRegularFileIdentity(mutation.path); + if ( + !identity || + identity.mode !== mutation.mode || + !(await fileMatchesIdentityAndHash(mutation.path, identity, mutation.originalHash)) + ) { + return false; + } + identities.set(mutation.path, identity); + return true; + }), + ); + return states.every(Boolean) ? identities : undefined; +} + +async function originalStateStillMatches( + mutation: IOSFileMutation, + initialIdentities: ReadonlyMap, +): Promise { + if (isCreateMutation(mutation)) { + return (await createParentStillMatches(mutation)) && (await pathIsAbsent(mutation.path)); + } + const identity = initialIdentities.get(mutation.path); + return ( + identity !== undefined && + fileMatchesIdentityAndHash(mutation.path, identity, mutation.originalHash) + ); +} + +async function originalStatesStillMatch( + mutations: readonly IOSFileMutation[], + initialIdentities: ReadonlyMap, +): Promise { + const matches = await Promise.all( + mutations.map(async (mutation) => originalStateStillMatches(mutation, initialIdentities)), + ); + return matches.every(Boolean); +} + +async function createdCandidateIsUntouched(item: StagedMutation): Promise { + if (!isCreateMutation(item.mutation) || !(await createParentStillMatches(item.mutation))) { + return false; + } + try { + const destinationIdentity = await readRegularFileIdentity(item.mutation.path); + if ( + !destinationIdentity || + !identitiesMatch(destinationIdentity, item.stagedIdentity) || + destinationIdentity.mode !== item.mutation.mode + ) { + return false; + } + if (item.temporaryPresent) { + const temporaryIdentity = await readRegularFileIdentity(item.temporaryPath); + if (!temporaryIdentity || !identitiesMatch(temporaryIdentity, item.stagedIdentity)) { + return false; + } + } + return ( + (await fileMatchesIdentityAndHash( + item.mutation.path, + item.committedIdentity ?? item.stagedIdentity, + item.mutation.candidateHash, + )) && (await createParentStillMatches(item.mutation)) + ); + } catch { + return false; + } +} + +async function committedCandidateIsUntouched(item: StagedMutation): Promise { + if (isCreateMutation(item.mutation)) return createdCandidateIsUntouched(item); + if (!item.committedIdentity) return false; + return fileMatchesIdentityAndHash( + item.mutation.path, + item.committedIdentity, + item.mutation.candidateHash, + ); +} + +async function committedCandidatesAreUntouched( + committed: readonly StagedMutation[], +): Promise { + const states = await Promise.all(committed.map(committedCandidateIsUntouched)); + return states.every(Boolean); +} + +async function claimedOriginalIsUntouched(item: StagedMutation): Promise { + if (isCreateMutation(item.mutation)) return true; + const claim = item.claimedOriginal; + return ( + claim !== undefined && + claim.present && + (await fileMatchesIdentityAndHash(claim.path, claim.identity, item.mutation.originalHash)) + ); +} + +async function claimedOriginalsAreUntouched( + committed: readonly StagedMutation[], +): Promise { + const states = await Promise.all(committed.map(claimedOriginalIsUntouched)); + return states.every(Boolean); +} + +async function cleanupClaimedOriginals(committed: readonly StagedMutation[]): Promise { + if ( + !(await committedCandidatesAreUntouched(committed)) || + !(await claimedOriginalsAreUntouched(committed)) + ) { + throw new IOSFileTransactionOwnershipError( + "one or more committed candidates or claimed originals changed before the originals could be released", + ); + } + const results = await Promise.allSettled( + committed.map(async (item) => { + if (isCreateMutation(item.mutation) || !item.claimedOriginal?.present) return; + if ( + !(await committedCandidateIsUntouched(item)) || + !(await claimedOriginalIsUntouched(item)) + ) { + throw new IOSFileTransactionOwnershipError( + "a committed candidate or claimed original changed before the original could be released", + ); + } + await removeClaimedPath(item.claimedOriginal, { + expectedHash: item.mutation.originalHash, + expectedMode: item.mutation.mode, + }); + }), + ); + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (failures.length > 0) { + const cause = aggregateCause(failures); + if (failures.every((failure) => failure instanceof IOSFileTransactionOwnershipError)) { + throw new IOSFileTransactionOwnershipError( + "one or more committed candidates or claimed originals changed before the originals could be released", + { cause }, + ); + } + throw cause; + } +} + +async function rollbackCommitted( + committed: readonly StagedMutation[], + hooks: IOSFileTransactionTestHooks, +): Promise { + if (committed.length === 0) return; + + const reversed = [...committed].reverse(); + const rollbackClaims: Array<{ + claim: ClaimedDestination; + mutation: IOSFileMutation; + }> = []; + const rollbackFiles = await stageAll( + reversed + .map((item) => item.mutation) + .filter((mutation): mutation is IOSExistingFileMutation => !isCreateMutation(mutation)), + (mutation) => { + if (isCreateMutation(mutation)) { + throw new Error("create mutations do not have original bytes"); + } + return { bytes: mutation.originalBytes, hash: mutation.originalHash }; + }, + ); + + try { + // Restore every still-owned existing file before attempting to remove a + // created dependency. A replaced create parent must not leave a project + // file pointing at a destination the transaction can no longer prove. + const candidatesAreUntouched = await Promise.all( + reversed + .filter((item) => !isCreateMutation(item.mutation)) + .map(committedCandidateIsUntouched), + ); + if (!candidatesAreUntouched.every(Boolean)) { + throw transactionError( + "rollback-failed", + "The iOS file transaction changed again before rollback; newer bytes were preserved.", + ); + } + + let rollbackIndex = 0; + for (const item of reversed) { + if (!(await committedCandidateIsUntouched(item))) { + throw transactionError( + "rollback-failed", + "The iOS file transaction changed again during rollback; newer bytes were preserved.", + ); + } + if (isCreateMutation(item.mutation)) { + const candidateIdentity = item.committedIdentity ?? item.stagedIdentity; + const claimResult = await claimDestination( + item.mutation.path, + candidateIdentity, + item.mutation.candidateHash, + ); + if (claimResult.status === "stale") { + throw transactionError( + "rollback-failed", + "The iOS file transaction changed again during rollback; newer bytes were preserved.", + ); + } + const candidateClaim = claimResult.claim; + rollbackClaims.push({ claim: candidateClaim, mutation: item.mutation }); + await removeClaimedPath(candidateClaim, { + expectedHash: item.mutation.candidateHash, + expectedMode: item.mutation.mode, + }); + await syncDirectory(dirname(item.mutation.path)); + if ( + !(await createParentStillMatches(item.mutation)) || + !(await pathIsAbsent(item.mutation.path)) + ) { + throw transactionError( + "rollback-failed", + "An iOS create destination changed while rollback removed its candidate; newer filesystem state was preserved.", + ); + } + continue; + } + const rollback = rollbackFiles[rollbackIndex++]; + if (!rollback) { + throw new Error("a prepared rollback file was missing"); + } + const originalBackup = item.claimedOriginal?.present ? item.claimedOriginal : undefined; + const originalSourcePath = originalBackup?.path ?? rollback.temporaryPath; + const originalSourceIdentity = await readRegularFileIdentity(originalSourcePath); + if ( + !originalSourceIdentity || + (originalBackup && !sameFile(originalSourceIdentity, originalBackup.identity)) || + (!originalBackup && + !(await fileMatchesIdentityAndHash( + originalSourcePath, + rollback.stagedIdentity, + item.mutation.originalHash, + ))) + ) { + throw new IOSFileTransactionOwnershipError( + "the original iOS file could not be identified during rollback", + ); + } + await hooks.beforeRollbackDestinationReplace?.(rollback.mutation.path, originalSourcePath); + if (!(await committedCandidateIsUntouched(item))) { + throw transactionError( + "rollback-failed", + "The iOS file transaction changed again during rollback; newer bytes were preserved.", + ); + } + const sourceIdentityBeforeRename = await readRegularFileIdentity(originalSourcePath); + if ( + !sourceIdentityBeforeRename || + !sameFile(sourceIdentityBeforeRename, originalSourceIdentity) + ) { + throw new IOSFileTransactionOwnershipError( + "the original iOS file changed before atomic rollback", + ); + } + // This final ownership check and the atomic rename are the standard + // replacement linearization boundary. A writer that wins afterward is + // ordered before this rollback; portable Node APIs do not provide an + // inode-conditional rename primitive. + await rename(originalSourcePath, rollback.mutation.path); + if (originalBackup) originalBackup.present = false; + else rollback.temporaryPresent = false; + await hooks.afterRollbackDestinationReplace?.(rollback.mutation.path); + const restoredIdentity = await readRegularFileIdentity(rollback.mutation.path); + if ( + !restoredIdentity || + !sameFile(restoredIdentity, originalSourceIdentity) || + (!originalBackup && + !(await fileMatchesIdentityAndHash( + rollback.mutation.path, + originalSourceIdentity, + item.mutation.originalHash, + ))) + ) { + throw new IOSFileTransactionOwnershipError( + "the restored iOS file did not match its rollback source", + ); + } + await syncDirectory(dirname(rollback.mutation.path)); + } + await cleanupStaged(rollbackFiles); + } catch (error) { + let candidateClaimCleanupError: unknown; + const candidateClaimCleanupResults = await Promise.allSettled( + rollbackClaims.map(async ({ claim, mutation }) => { + if (!claim.present) return; + await removeClaimedPath(claim, { + expectedHash: mutation.candidateHash, + expectedMode: mutation.mode, + }); + }), + ); + const candidateClaimCleanupFailures = candidateClaimCleanupResults + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (candidateClaimCleanupFailures.length > 0) { + candidateClaimCleanupError = aggregateCause(candidateClaimCleanupFailures); + } + try { + await cleanupStaged(rollbackFiles); + } catch (cleanupError) { + throw transactionError( + "rollback-failed", + "The iOS file transaction could not be rolled back or cleaned up completely.", + aggregateCause( + [error, candidateClaimCleanupError, cleanupError].filter((cause) => cause !== undefined), + ), + ); + } + if (candidateClaimCleanupError) { + throw transactionError( + "rollback-failed", + "The iOS file transaction could not be rolled back or cleaned up completely.", + aggregateCause([error, candidateClaimCleanupError].filter((cause) => cause !== undefined)), + ); + } + if (error instanceof IOSFileTransactionError && error.code === "rollback-failed") throw error; + throw transactionError( + "rollback-failed", + "The iOS file transaction could not be rolled back completely.", + error, + ); + } +} + +async function rollbackAfterFailure( + committed: readonly StagedMutation[], + staged: readonly StagedMutation[], + hooks: IOSFileTransactionTestHooks, +): Promise { + let rollbackError: unknown; + let cleanupError: unknown; + try { + await rollbackCommitted(committed, hooks); + } catch (error) { + rollbackError = error; + } + try { + await cleanupStaged(staged); + } catch (error) { + cleanupError = error; + } + if (!(await createParentsStillMatch(committed.map((item) => item.mutation)))) { + cleanupError ??= transactionError( + "rollback-failed", + "An iOS create destination changed while rollback was being finalized; newer filesystem state was preserved.", + ); + } + if (rollbackError || cleanupError) { + throw transactionError( + "rollback-failed", + "The iOS file transaction could not be rolled back or cleaned up completely.", + aggregateCause([rollbackError, cleanupError].filter((error) => error !== undefined)), + ); + } +} + +async function postconditionsAreValid( + postconditions: readonly IOSFilePostcondition[], +): Promise { + const results = await Promise.allSettled( + postconditions.map(async (postcondition) => Promise.resolve().then(postcondition)), + ); + return results.every((result) => result.status === "fulfilled" && result.value === true); +} + +/** + * Atomically creates or replaces a set of files as far as the filesystem + * allows. Candidates are all staged first; any partial commit is restored in + * reverse order if a later commit or aggregate postcondition fails. + * + * @internal Prepared mutations contain sensitive-to-output candidate bytes. + */ +export async function applyIOSFileTransaction( + mutations: readonly IOSFileMutation[], + postconditions: readonly IOSFilePostcondition[], + hooks: IOSFileTransactionTestHooks = {}, +): Promise { + const prepared = snapshotMutations(mutations); + validateMutations(prepared); + const initialIdentities = await captureInitialExistingFileIdentities(prepared); + if (!initialIdentities) return { status: "stale" }; + let staged: StagedMutation[]; + try { + staged = await stageAll(prepared, (mutation) => ({ + bytes: mutation.candidateBytes, + hash: mutation.candidateHash, + })); + } catch (error) { + if (error instanceof IOSFileTransactionStaleError) return { status: "stale" }; + throw error; + } + const committed: StagedMutation[] = []; + + if (!(await originalStatesStillMatch(prepared, initialIdentities))) { + await cleanupStaged(staged); + return { status: "stale" }; + } + + let stale = false; + let commitError: unknown; + for (const item of staged) { + if (!(await createParentsStillMatch(prepared))) { + stale = true; + break; + } + const originalStillMatches = await originalStateStillMatches(item.mutation, initialIdentities); + if (!originalStillMatches) { + stale = true; + break; + } + let committedThisItem = false; + try { + if (isCreateMutation(item.mutation)) { + await link(item.temporaryPath, item.mutation.path); + } else { + const expectedIdentity = initialIdentities.get(item.mutation.path); + if (!expectedIdentity) { + throw new Error("an existing iOS file identity was missing during commit"); + } + await hooks.beforeExistingDestinationBackup?.(item.mutation.path); + const backupResult = await backupDestination( + item.mutation.path, + expectedIdentity, + item.mutation.originalHash, + ); + if (backupResult.status === "stale") { + stale = true; + break; + } + item.claimedOriginal = backupResult.claim; + await hooks.afterExistingDestinationBackup?.(item.mutation.path, item.claimedOriginal.path); + await hooks.beforeExistingDestinationReplace?.( + item.mutation.path, + item.claimedOriginal.path, + ); + if ( + !(await createParentsStillMatch(prepared)) || + !(await originalStateStillMatches(item.mutation, initialIdentities)) || + !(await fileMatchesIdentityAndHash( + item.temporaryPath, + item.stagedIdentity, + item.mutation.candidateHash, + )) + ) { + throw new IOSFileTransactionStaleError(); + } + // The original remains available through the verified hard-link + // backup while this same-directory rename atomically replaces the + // public destination. There is no path-absence window. The final + // ownership check and rename form the standard replacement + // linearization boundary; portable Node APIs do not provide an + // inode-conditional rename primitive. + await rename(item.temporaryPath, item.mutation.path); + item.temporaryPresent = false; + } + // A successful create link or existing-file rename proves that the + // destination referred to the staged inode at the commit linearization + // point. Record ownership synchronously before exposing it to rollback. + item.committedIdentity = item.stagedIdentity; + committed.push(item); + committedThisItem = true; + if (!isCreateMutation(item.mutation) && item.claimedOriginal) { + await hooks.afterExistingDestinationReplace?.( + item.mutation.path, + item.claimedOriginal.path, + ); + } + if (!(await createParentsStillMatch(prepared))) { + throw new IOSFileTransactionStaleError(); + } + const committedIdentity = await readRegularFileIdentity(item.mutation.path); + if (!committedIdentity || !identitiesMatch(committedIdentity, item.stagedIdentity)) { + throw new Error("committed file identity did not match its staged candidate"); + } + item.committedIdentity = committedIdentity; + await syncDirectory(dirname(item.mutation.path)); + if (!(await createParentsStillMatch(prepared))) { + throw new IOSFileTransactionStaleError(); + } + } catch (error) { + let effectiveError = error; + if (!committedThisItem && item.claimedOriginal?.present) { + try { + await removeClaimedPath(item.claimedOriginal); + } catch (cleanupError) { + effectiveError = new IOSFileTransactionOwnershipError( + "an original-file backup could not be released after commit stopped", + { cause: aggregateCause([error, cleanupError]) }, + ); + } + } + if ( + effectiveError instanceof IOSFileTransactionStaleError || + (isCreateMutation(item.mutation) && isFileSystemError(effectiveError, "EEXIST")) + ) { + stale = true; + } else { + commitError = effectiveError; + } + break; + } + } + + if (stale || commitError) { + await rollbackAfterFailure(committed, staged, hooks); + if (stale) return { status: "stale" }; + throw transactionError( + "commit-failed", + "The iOS file transaction could not be committed and was restored.", + commitError, + ); + } + + const parentsMatchedBeforePostvalidation = await createParentsStillMatch(prepared); + const candidatesMatchedBeforePostvalidation = await committedCandidatesAreUntouched(committed); + const originalsMatchedBeforePostvalidation = await claimedOriginalsAreUntouched(committed); + const postconditionsValid = await postconditionsAreValid(postconditions); + const parentsMatchedAfterPostvalidation = await createParentsStillMatch(prepared); + const candidatesMatchedAfterPostvalidation = await committedCandidatesAreUntouched(committed); + const originalsMatchedAfterPostvalidation = await claimedOriginalsAreUntouched(committed); + if ( + parentsMatchedBeforePostvalidation && + candidatesMatchedBeforePostvalidation && + originalsMatchedBeforePostvalidation && + postconditionsValid && + parentsMatchedAfterPostvalidation && + candidatesMatchedAfterPostvalidation && + originalsMatchedAfterPostvalidation + ) { + try { + await cleanupClaimedOriginals(committed); + } catch (error) { + await rollbackAfterFailure(committed, staged, hooks); + if (error instanceof IOSFileTransactionOwnershipError) return { status: "stale" }; + throw transactionError( + "cleanup-failed", + "The iOS file transaction could not release its claimed original files and was restored.", + error, + ); + } + await cleanupStaged(staged); + if ( + (await createParentsStillMatch(prepared)) && + (await committedCandidatesAreUntouched(committed)) + ) { + return { status: "applied" }; + } + await rollbackAfterFailure(committed, staged, hooks); + return { status: "stale" }; + } + + await rollbackAfterFailure(committed, staged, hooks); + return parentsMatchedBeforePostvalidation && + candidatesMatchedBeforePostvalidation && + parentsMatchedAfterPostvalidation && + candidatesMatchedAfterPostvalidation && + originalsMatchedBeforePostvalidation && + originalsMatchedAfterPostvalidation + ? { status: "rolled-back" } + : { status: "stale" }; +} + +/** + * Atomically replaces a set of existing files as far as the filesystem allows. + * + * @internal Prepared mutations contain sensitive-to-output candidate bytes. + */ +export async function applyIOSExistingFileTransaction( + mutations: readonly IOSExistingFileMutation[], + postconditions: readonly IOSFilePostcondition[], + hooks: IOSFileTransactionTestHooks = {}, +): Promise { + return applyIOSFileTransaction(mutations, postconditions, hooks); +} diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts new file mode 100644 index 000000000..2f1d01c8a --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -0,0 +1,589 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { inspectWorkspace } from "./discovery.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("inspectIOSProject", () => { + test("inspects target settings, Clerk linkage, entitlements, and Swift setup", async () => { + const root = await fixture({ complete: true, workspace: true }); + const inspection = await inspectIOSProject(root); + + expect(inspection.selection).toEqual({ + state: "selected", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + projectPath: "MyApp.xcodeproj", + }); + expect(inspection.workspaces).toEqual([ + { path: "MyApp.xcworkspace", projectPaths: ["MyApp.xcodeproj"] }, + ]); + expect(inspection.projects[0]?.packages[0]).toMatchObject({ + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + isClerk: true, + }); + + const target = inspection.appTargets[0]; + expect(target?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(target?.configurations.map((configuration) => configuration.name)).toEqual([ + "Debug", + "Release", + ]); + expect(target?.configurations[0]?.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.MyApp", + }); + expect(target?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + literalAppIdentifierPrefix: "LEGACY1234", + teamIdentifier: "ABCDE12345", + }); + expect(target?.swift).toMatchObject({ + status: "complete", + sourceFilesScanned: 1, + }); + expect(target?.runtimeKeySinks).toEqual([]); + expect(inspection.localPublishableKey).toEqual({ + found: true, + source: ".env", + frontendApiHost: "clerk.example.test", + instanceType: "development", + conflict: false, + candidateSources: [".env"], + invalidSources: [], + }); + const fixtureKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; + expect(JSON.stringify(inspection)).not.toContain(fixtureKey); + + const fromWorkspaceBundle = await inspectIOSProject(join(root, "MyApp.xcworkspace")); + expect(fromWorkspaceBundle.selection).toMatchObject({ + state: "selected", + targetName: "MyApp", + }); + + const embeddedWorkspace = join(root, "MyApp.xcodeproj", "project.xcworkspace"); + await mkdir(embeddedWorkspace, { recursive: true }); + await Bun.write( + join(embeddedWorkspace, "contents.xcworkspacedata"), + '', + ); + const fromEmbeddedWorkspace = await inspectIOSProject(embeddedWorkspace); + expect(fromEmbeddedWorkspace.selection).toMatchObject({ + state: "selected", + targetName: "MyApp", + projectPath: "MyApp.xcodeproj", + }); + }); + + test("does not synthesize workspace markup across XML comments", async () => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ` + leRef location="group:Injected.xcodeproj"> + + + `, + ); + + const result = await inspectWorkspace(root, workspace); + + expect(result.inspection.projectPaths).toEqual(["MyApp.xcodeproj"]); + expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); + }); + + test("does not parse a workspace location token from inside another quoted attribute", async () => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ` + + `, + ); + + const result = await inspectWorkspace(root, workspace); + + expect(result.inspection.projectPaths).toEqual(["MyApp.xcodeproj"]); + expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); + }); + + test("does not guess when multiple application targets exist", async () => { + const root = await fixture({ secondTarget: true }); + const inspection = await inspectIOSProject(root); + + expect(inspection.selection.state).toBe("ambiguous"); + expect( + inspection.diagnostics.some((diagnostic) => diagnostic.code === "xcode.ambiguous-app-target"), + ).toBe(true); + }); + + test("selects an explicit target by name or object ID", async () => { + const root = await fixture({ secondTarget: true }); + + const byName = await inspectIOSProject(root, { target: "AdminApp" }); + const byId = await inspectIOSProject(root, { target: IOS_FIXTURE_IDS.appTarget }); + + expect(byName.selection).toMatchObject({ state: "selected", targetName: "AdminApp" }); + expect(byId.selection).toMatchObject({ state: "selected", targetName: "MyApp" }); + }); + + test("reports usable target choices when an explicit target is not found", async () => { + const root = await fixture({ secondTarget: true }); + + const inspection = await inspectIOSProject(root, { target: "MissingApp" }); + + expect(inspection.selection).toEqual({ + state: "not-found", + requested: "MissingApp", + candidates: [ + `AdminApp (${IOS_FIXTURE_IDS.secondTarget})`, + `MyApp (${IOS_FIXTURE_IDS.appTarget})`, + ], + }); + expect(inspection.projects[0]?.appTargetIds).toEqual([ + IOS_FIXTURE_IDS.secondTarget, + IOS_FIXTURE_IDS.appTarget, + ]); + }); + + test("does not treat watchOS application products as iOS app candidates", async () => { + const root = await fixture({ secondTarget: "watchos" }); + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets.map((target) => target.name)).toEqual(["MyApp"]); + expect(inspection.selection).toMatchObject({ state: "selected", targetName: "MyApp" }); + }); + + test("preserves conflicting configuration values instead of guessing", async () => { + const root = await fixture({ conflictingBundle: true }); + const inspection = await inspectIOSProject(root); + + expect( + inspection.appTargets[0]?.configurations.map((configuration) => + configuration.bundleIdentifier.state === "resolved" + ? configuration.bundleIdentifier.value + : configuration.bundleIdentifier.state, + ), + ).toEqual(["com.example.MyApp", "com.example.MyApp.release"]); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.conflicting-build-setting", + ), + ).toBe(true); + }); + + test("reports a setting present in only one build configuration", async () => { + const root = await fixture({ releaseEntitlements: false }); + const inspection = await inspectIOSProject(root); + + expect( + inspection.diagnostics.some( + (diagnostic) => + diagnostic.code === "xcode.conflicting-build-setting" && + diagnostic.message.includes("Release="), + ), + ).toBe(true); + }); + + test("resolves checked-in xcconfig includes and variables without Xcode", async () => { + const root = await fixture({ xcconfig: true }); + const inspection = await inspectIOSProject(root); + const configurations = inspection.appTargets[0]?.configurations; + + expect(configurations?.map((configuration) => configuration.bundleIdentifier)).toEqual([ + expect.objectContaining({ state: "resolved", value: "com.example.MyApp" }), + expect.objectContaining({ state: "resolved", value: "com.example.MyApp" }), + ]); + expect(configurations?.map((configuration) => configuration.developmentTeam)).toEqual([ + expect.objectContaining({ state: "resolved", value: "ABCDE12345" }), + expect.objectContaining({ state: "resolved", value: "ABCDE12345" }), + ]); + }); + + test("reads a target-owned LocalSecrets.plist without exposing its key", async () => { + const root = await fixture({ includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: true, + source: "MyApp/LocalSecrets.plist", + frontendApiHost: "native.clerk.example", + instanceType: "production", + conflict: false, + candidateSources: ["MyApp/LocalSecrets.plist"], + invalidSources: [], + }); + expect(inspection.appTargets[0]?.runtimeKeySinks).toEqual([ + { kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }, + ]); + expect(JSON.stringify(inspection)).not.toContain("pk_live_"); + }); + + test("treats a direct @main literal as the selected target's runtime key without exposing it", async () => { + const root = await fixture({ includeKey: false }); + const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${publishableKey}") + } + + var body: some Scene { + WindowGroup { + Text("Hello") + .environment(Clerk.shared) + } + } +} +`, + ); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + + expect(inspection.localPublishableKey).toEqual({ + found: true, + conflict: false, + source: "MyApp/MyAppApp.swift", + frontendApiHost: "inline.clerk.example", + instanceType: "development", + candidateSources: ["MyApp/MyAppApp.swift"], + invalidSources: [], + }); + expect(JSON.stringify(inspection)).not.toContain(publishableKey); + }); + + test("reads an enabled publishable key from the selected target's Run scheme", async () => { + const root = await fixture({ includeKey: false }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toMatchObject({ + found: true, + source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + frontendApiHost: "scheme.clerk.example", + conflict: false, + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + }); + + test("ignores a workspace scheme that references a different same-named project container", async () => { + const root = await fixture({ includeKey: false, workspace: true }); + const schemeDirectory = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemeKey = `pk_test_${Buffer.from("wrong-container.clerk.example$").toString("base64")}`; + await Bun.write( + join(schemeDirectory, "WrongContainer.xcscheme"), + ``, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + }); + + test("blocks derived advice when equally effective keys point to different instances", async () => { + const root = await fixture({ includeKey: false }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const firstKey = `pk_test_${Buffer.from("first.clerk.example$").toString("base64")}`; + const secondKey = `pk_live_${Buffer.from("second.clerk.example$").toString("base64")}`; + const scheme = (key: string) => + ``; + await Bun.write(join(schemeDirectory, "First.xcscheme"), scheme(firstKey)); + await Bun.write(join(schemeDirectory, "Second.xcscheme"), scheme(secondKey)); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: true, + source: "MyApp.xcodeproj/xcshareddata/xcschemes/First.xcscheme", + conflict: true, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/First.xcscheme", + "MyApp.xcodeproj/xcshareddata/xcschemes/Second.xcscheme", + ], + invalidSources: [], + }); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "clerk.conflicting-publishable-keys", + ), + ).toBe(true); + expect(JSON.stringify(inspection)).not.toContain(firstKey); + expect(JSON.stringify(inspection)).not.toContain(secondKey); + }); + + test("does not conflict a selected-target runtime key with the CLI process environment", async () => { + const root = await fixture({ includeKey: false, localSecrets: true }); + const ambientKey = `pk_test_${Buffer.from("ambient.clerk.example$").toString("base64")}`; + const previousAmbientKey = process.env.CLERK_PUBLISHABLE_KEY; + process.env.CLERK_PUBLISHABLE_KEY = ambientKey; + + try { + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toMatchObject({ + found: true, + conflict: false, + source: "MyApp/LocalSecrets.plist", + frontendApiHost: "native.clerk.example", + }); + expect(inspection.localPublishableKey.candidateSources).toEqual([ + "CLERK_PUBLISHABLE_KEY environment variable", + "MyApp/LocalSecrets.plist", + ]); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "clerk.conflicting-publishable-keys", + ), + ).toBe(false); + expect(JSON.stringify(inspection)).not.toContain(ambientKey); + } finally { + if (previousAmbientKey == null) delete process.env.CLERK_PUBLISHABLE_KEY; + else process.env.CLERK_PUBLISHABLE_KEY = previousAmbientKey; + } + }); + + test("does not fall through when the highest-precedence key is malformed", async () => { + const root = await fixture({ includeKey: false, localSecrets: true }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemePath = join(schemeDirectory, "MyApp.xcscheme"); + await Bun.write( + schemePath, + ``, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + ], + invalidSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], + }); + }); + + test("does not treat web-framework key names as native iOS configuration", async () => { + const root = await fixture({ includeKey: false }); + const webKey = `pk_test_${Buffer.from("web.clerk.example$").toString("base64")}`; + await Bun.write(join(root, ".env"), `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${webKey}\n`); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + }); + + test("does not follow an entitlements symlink outside the inspected root", async () => { + if (process.platform === "win32") return; + const root = await fixture(); + const externalRoot = await mkdtemp(join(tmpdir(), "clerk-ios-external-")); + temporaryDirectories.push(externalRoot); + const externalEntitlements = join(externalRoot, "External.entitlements"); + await Bun.write(externalEntitlements, ""); + await rm(join(root, "MyApp", "MyApp.entitlements")); + await symlink(externalEntitlements, join(root, "MyApp", "MyApp.entitlements")); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toBeUndefined(); + expect( + inspection.diagnostics.some((diagnostic) => diagnostic.code === "xcode.external-path"), + ).toBe(true); + }); + + test("honors synchronized-group membership exceptions and iOS platform filters", async () => { + const root = await fixture({ complete: false }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + const synchronizedObjects = ` + 404040404040404040404040 = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 414141414141414141414141, 424242424242424242424242, ); path = Synced; sourceTree = ""; }; + 414141414141414141414141 = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "Excluded/Auth.swift", ); platformFiltersByRelativePath = { MacOnly.swift = ( macos, ); }; target = ${IOS_FIXTURE_IDS.appTarget}; }; + 424242424242424242424242 = { isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; buildPhase = ${IOS_FIXTURE_IDS.sourcesPhase}; membershipExceptions = ( PhaseExcluded.swift, ); }; + `; + await Bun.write( + projectFile, + original + .replace( + `productType = "com.apple.product-type.application";\n packageProductDependencies`, + `productType = "com.apple.product-type.application";\n fileSystemSynchronizedGroups = ( 404040404040404040404040, );\n packageProductDependencies`, + ) + .replace( + `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + `${synchronizedObjects}\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + ), + ); + await mkdir(join(root, "Synced", "Included"), { recursive: true }); + await mkdir(join(root, "Synced", "Excluded"), { recursive: true }); + await Bun.write( + join(root, "Synced", "Included", "Auth.swift"), + "import ClerkKitUI\nstruct Included { let view = AuthView() }\n", + ); + await Bun.write( + join(root, "Synced", "Excluded", "Auth.swift"), + "import ClerkKitUI\nstruct Excluded { let view = AuthView() }\n", + ); + await Bun.write( + join(root, "Synced", "PhaseExcluded.swift"), + "import ClerkKit\nfunc excluded() { Clerk.configure(publishableKey: key) }\n", + ); + await Bun.write( + join(root, "Synced", "MacOnly.swift"), + "import ClerkKit\nfunc macOnly() { Clerk.configure(publishableKey: key) }\n", + ); + + const inspection = await inspectIOSProject(root); + const swift = inspection.appTargets[0]?.swift; + + expect(swift?.sourceFilesScanned).toBe(2); + expect(swift?.authFlowReferences).toEqual([{ path: "Synced/Included/Auth.swift" }]); + expect(swift?.configureCalls).toEqual([]); + }); + + test("does not use Catalyst-only classic build-file membership as native iOS evidence", async () => { + const root = await fixture({ complete: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + await Bun.write( + projectFile, + original + .replace( + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef`, + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; platformFilters = ( maccatalyst, ); fileRef`, + ) + .replace( + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; productRef`, + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; platformFilters = ( maccatalyst, ); productRef`, + ), + ); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + + expect(target?.swift.sourceFilesScanned).toBe(0); + expect(target?.swift.configureCalls).toEqual([]); + expect(target?.packages.clerkKit).toBe("declared"); + }); + + test("does not use unknown classic build-file filters as authoritative iOS evidence", async () => { + const root = await fixture({ complete: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + await Bun.write( + projectFile, + original + .replace( + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef`, + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; platformFilter = futureos; fileRef`, + ) + .replace( + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; productRef`, + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; platformFilter = futureos; productRef`, + ), + ); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + + expect(target?.swift.sourceFilesScanned).toBe(0); + expect(target?.swift.evidenceComplete).toBe(false); + expect(target?.packages.clerkKit).toBe("declared"); + }); + + test("does not use non-iOS or unknown LocalSecrets resource membership as a native key", async () => { + for (const platformFilter of ["maccatalyst", "futureos"]) { + const root = await fixture({ includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + await Bun.write( + projectFile, + original.replace( + `${IOS_FIXTURE_IDS.localSecretsBuildFile} = { isa = PBXBuildFile; fileRef`, + `${IOS_FIXTURE_IDS.localSecretsBuildFile} = { isa = PBXBuildFile; platformFilter = ${platformFilter}; fileRef`, + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + } + }); + + test("reports malformed projects as blocked evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); + temporaryDirectories.push(root); + await mkdir(join(root, "Broken.xcodeproj")); + await Bun.write(join(root, "Broken.xcodeproj", "project.pbxproj"), "{ objects = ("); + + const inspection = await inspectIOSProject(root); + + expect(inspection.selection.state).toBe("none"); + expect( + inspection.diagnostics.some((diagnostic) => diagnostic.code === "xcode.malformed-project"), + ).toBe(true); + }); + + test("detects generated projects and never mutates the inspected tree", async () => { + const root = await fixture({ complete: true, generated: "xcodegen" }); + const before = await treeDigest(root); + + const inspection = await inspectIOSProject(root); + + expect(inspection.generatedProject).toBe("xcodegen"); + expect(await treeDigest(root)).toEqual(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts new file mode 100644 index 000000000..8a0f773a4 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -0,0 +1,1662 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, extname, relative, resolve, sep } from "node:path"; +import plist from "@expo/plist"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { parseEnvFile } from "../../../lib/dotenv.ts"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { + addBuildSettingConflictDiagnostics, + inspectTargetBuildConfigurations, + resolveEntitlementsAbsolutePath, +} from "./build-settings.ts"; +import { + discoverIOSContainers, + inspectWorkspace, + pathIsSafelyWithinIOSRoot, + relativeIOSPath, + xmlAttribute, +} from "./discovery.ts"; +import { + asString, + asStringArray, + asStringRecord, + buildPbxParentIndex, + isClerkIOSRepository, + isRecord, + resolvePbxFilePath, + sanitizeRepositoryURL, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; +import { inspectSwiftSources } from "./swift.ts"; +import type { + IOSAppTarget, + IOSBuildConfiguration, + IOSClerkPackageState, + IOSDiagnostic, + IOSEntitlementsInspection, + IOSPackageReference, + IOSProductLinkState, + IOSProjectInspection, + IOSProjectInspectionResult, + IOSSourceEvidence, + IOSTargetSelection, +} from "./types.ts"; + +const APP_PRODUCT_TYPE = "com.apple.product-type.application"; +const MAX_PBXPROJ_BYTES = 15_000_000; +const MAX_SOURCE_FILES = 2_500; +const MAX_SOURCE_DEPTH = 24; +const MAX_SECRET_DISCOVERY_DEPTH = 5; +const MAX_SECRET_FILES = 20; +const SOURCE_IGNORES = new Set([ + ".build", + ".git", + ".swiftpm", + "build", + "Carthage", + "DerivedData", + "Pods", + "SourcePackages", +]); + +interface ParsedProject { + inspection: IOSProjectInspection; + appTargets: IOSAppTarget[]; + appTargetCandidates: Array<{ targetId: string; targetName: string; projectPath: string }>; + diagnostics: IOSDiagnostic[]; + sourceMemberships?: IOSTargetSourceMembership[]; +} + +export interface IOSTargetSourceMembership { + targetId: string; + targetName: string; + projectPath: string; + files: Array<{ absolutePath: string; relativePath: string }>; + complete: boolean; +} + +const sourceMembershipByInspection = new WeakMap< + IOSProjectInspectionResult, + IOSTargetSourceMembership[] +>(); + +function emptySwiftInspection() { + return { + sourceFilesScanned: 0, + evidenceComplete: false, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + localSecretsRuntimeBindings: [], + environmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent" as const, + }; +} + +function normalizeObjects(value: unknown): PbxObjects | undefined { + if (!isRecord(value)) return undefined; + const objects: PbxObjects = {}; + for (const [id, object] of Object.entries(value)) { + if (isRecord(object)) objects[id] = object; + } + return objects; +} + +function canonicalRequirement(value: unknown): Record | undefined { + const requirement = asStringRecord(value); + return Object.keys(requirement).length > 0 ? requirement : undefined; +} + +function buildFileIOSApplicability(object: PbxObject): { + applies: boolean; + recognized: boolean; +} { + const platformFilter = asString(object.platformFilter); + const filters = [ + ...asStringArray(object.platformFilters), + ...(platformFilter ? [platformFilter] : []), + ]; + if (filters.length === 0) return { applies: true, recognized: true }; + if (filters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter))) { + return { applies: true, recognized: true }; + } + const recognized = filters.every((filter) => + /(?:maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)/i.test(filter), + ); + return { applies: false, recognized }; +} + +interface PublishableKeyCandidate { + value?: string; + decoded?: { frontendApiHost: string; instanceType: "development" | "production" }; + invalid?: true; + source: string; + evidence: IOSSourceEvidence[]; + priority: number; + ambient?: true; +} + +async function collectSchemeFiles( + root: string, + directory: string, + output: string[], + depth = 0, +): Promise { + if (depth > 6 || output.length >= 100) return; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + await collectSchemeFiles(root, path, output, depth + 1); + } else if ( + entry.isFile() && + entry.name.endsWith(".xcscheme") && + (await pathIsSafelyWithinIOSRoot(root, path)) + ) { + output.push(path); + } + } +} + +function enclosingXcodeContainer(path: string): string | undefined { + let current = dirname(path); + while (true) { + if (current.endsWith(".xcodeproj") || current.endsWith(".xcworkspace")) return current; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +function schemeReferencesSelectedProject( + root: string, + schemePath: string, + selectedProjectPath: string, + referencedContainer: string | undefined, +): boolean { + const selectedProject = resolve(root, selectedProjectPath); + const enclosingContainer = enclosingXcodeContainer(schemePath); + if (!referencedContainer) { + return ( + enclosingContainer?.endsWith(".xcodeproj") === true && enclosingContainer === selectedProject + ); + } + const base = enclosingContainer ? dirname(enclosingContainer) : root; + const normalized = referencedContainer.replaceAll("\\", "/"); + return resolve(base, ...normalized.split("/")) === selectedProject; +} + +async function schemePublishableKeyCandidates( + root: string, + selection: IOSTargetSelection, + schemeRoots: string[], +): Promise { + if (selection.state !== "selected") return []; + const schemePaths: string[] = []; + for (const schemeRoot of [...new Set(schemeRoots)].sort()) { + if (await pathIsSafelyWithinIOSRoot(root, schemeRoot)) { + await collectSchemeFiles(root, schemeRoot, schemePaths); + } + } + const candidates: PublishableKeyCandidate[] = []; + + for (const path of schemePaths.sort()) { + const file = Bun.file(path); + if (!(await file.exists()) || file.size > 2_000_000) continue; + let xml: string; + try { + xml = (await file.text()).replace(//g, ""); + } catch { + continue; + } + + for (const launchAction of xml.matchAll(/]*>([\s\S]*?)<\/LaunchAction>/g)) { + const body = launchAction[1] ?? ""; + const referencesTarget = [...body.matchAll(/]*)>/g)].some( + (reference) => { + const attributes = reference[1] ?? ""; + if (xmlAttribute(attributes, "BlueprintIdentifier") !== selection.targetId) { + return false; + } + const container = xmlAttribute(attributes, "ReferencedContainer")?.replace( + /^container:/, + "", + ); + return schemeReferencesSelectedProject(root, path, selection.projectPath, container); + }, + ); + if (!referencesTarget) continue; + + for (const variable of body.matchAll(/]*)\/?\s*>/g)) { + const attributes = variable[1] ?? ""; + if (xmlAttribute(attributes, "key") !== "CLERK_PUBLISHABLE_KEY") continue; + if ((xmlAttribute(attributes, "isEnabled") ?? "YES").toUpperCase() === "NO") continue; + const value = xmlAttribute(attributes, "value")?.trim(); + if (!value) continue; + const source = relativeIOSPath(root, path); + candidates.push({ + value, + source, + evidence: [{ path: source, keyPath: "LaunchAction.EnvironmentVariables" }], + priority: 5, + }); + } + } + } + return candidates; +} + +async function collectLocalSecretsPlists( + root: string, + directory: string, + output: string[], + depth = 0, +): Promise { + if (depth > MAX_SECRET_DISCOVERY_DEPTH || output.length >= MAX_SECRET_FILES) return; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (output.length >= MAX_SECRET_FILES) return; + const absolutePath = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { + await collectLocalSecretsPlists(root, absolutePath, output, depth + 1); + } + } else if ( + entry.isFile() && + entry.name === "LocalSecrets.plist" && + (await pathIsSafelyWithinIOSRoot(root, absolutePath)) + ) { + output.push(absolutePath); + } + } +} + +async function readPublishableKeyCandidates( + root: string, + selection: IOSTargetSelection, + targetLocalSecretsPaths: string[], + schemeRoots: string[], + inlineCandidates: PublishableKeyCandidate[], +): Promise { + const selectedProjectDirectory = + selection.state === "selected" ? dirname(resolve(root, selection.projectPath)) : root; + const projectDirectories = [...new Set([selectedProjectDirectory, root])]; + const candidates: PublishableKeyCandidate[] = [ + ...inlineCandidates, + ...(await schemePublishableKeyCandidates(root, selection, schemeRoots)), + ]; + + for (const directory of projectDirectories) { + for (const [fileName, priority] of [ + [".env.local", 20], + [".env", 30], + ] as const) { + const path = resolve(directory, fileName); + if (!(await pathIsSafelyWithinIOSRoot(root, path))) continue; + const file = Bun.file(path); + if (!(await file.exists()) || file.size > 1_000_000) continue; + try { + for (const line of parseEnvFile(await file.text())) { + if (line.type === "entry" && line.key === "CLERK_PUBLISHABLE_KEY" && line.value) { + candidates.push({ + value: line.value, + source: relativeIOSPath(root, path), + evidence: [{ path: relativeIOSPath(root, path), keyPath: line.key }], + priority, + }); + } + } + } catch { + // A partially-written env file is not evidence of a usable key. + } + } + } + + for (const path of targetLocalSecretsPaths) { + const file = Bun.file(path); + if (!(await file.exists()) || file.size > 1_000_000) continue; + try { + const parsed: unknown = plist.parse(await file.text()); + const value = isRecord(parsed) ? asString(parsed.CLERK_PUBLISHABLE_KEY) : undefined; + if (value) { + candidates.push({ + value, + source: relativeIOSPath(root, path), + evidence: [{ path: relativeIOSPath(root, path), keyPath: "CLERK_PUBLISHABLE_KEY" }], + priority: 10, + }); + } + } catch { + // Binary/malformed secret files are ignored rather than printing parser data. + } + } + + for (const directory of projectDirectories) { + const path = resolve(directory, ".clerk", ".tmp", "keyless.json"); + if (!(await pathIsSafelyWithinIOSRoot(root, path))) continue; + const file = Bun.file(path); + if (!(await file.exists()) || file.size > 1_000_000) continue; + try { + const parsed: unknown = await file.json(); + const value = isRecord(parsed) ? asString(parsed.publishableKey) : undefined; + if (value) { + candidates.push({ + value, + source: relativeIOSPath(root, path), + evidence: [{ path: relativeIOSPath(root, path), keyPath: "publishableKey" }], + priority: 40, + }); + } + } catch { + // A partially-written SDK keyless file is not evidence of a usable key. + } + } + + const ambient = process.env.CLERK_PUBLISHABLE_KEY; + if (ambient) { + candidates.push({ + value: ambient, + source: "CLERK_PUBLISHABLE_KEY environment variable", + evidence: [], + priority: 50, + ambient: true, + }); + } + + return candidates.sort((a, b) => a.priority - b.priority || a.source.localeCompare(b.source)); +} + +async function inspectLocalPublishableKeys( + root: string, + selection: IOSTargetSelection, + targetLocalSecretsPaths: string[], + schemeRoots: string[], + inlineCandidates: PublishableKeyCandidate[], + diagnostics: IOSDiagnostic[], +): Promise { + const candidates = await readPublishableKeyCandidates( + root, + selection, + targetLocalSecretsPaths, + schemeRoots, + inlineCandidates, + ); + const candidateSources = [...new Set(candidates.map((candidate) => candidate.source))].sort(); + const decodedCandidates: Array<{ + candidate: PublishableKeyCandidate; + decoded?: { frontendApiHost: string; instanceType: "development" | "production" }; + }> = []; + const invalidSources = new Set(); + for (const candidate of candidates) { + if (candidate.decoded) { + decodedCandidates.push({ candidate, decoded: candidate.decoded }); + continue; + } + try { + if (candidate.invalid || candidate.value == null) throw new Error("invalid candidate"); + const value = decodePublishableKey(candidate.value); + decodedCandidates.push({ + candidate, + decoded: { frontendApiHost: value.fapiHost, instanceType: value.instanceType }, + }); + } catch { + invalidSources.add(candidate.source); + decodedCandidates.push({ candidate }); + diagnostics.push({ + code: "clerk.invalid-publishable-key", + severity: "warning", + message: `A publishable key candidate from ${candidate.source} has an invalid format.`, + remedy: "Replace it with a valid pk_test_ or pk_live_ publishable key.", + evidence: candidate.evidence, + }); + } + } + + const localCandidates = decodedCandidates.filter((item) => !item.candidate.ambient); + const ambientCandidates = decodedCandidates.filter((item) => item.candidate.ambient); + const effectivePriority = localCandidates[0]?.candidate.priority; + const effectiveCandidates = + effectivePriority == null + ? ambientCandidates + : localCandidates.filter((item) => item.candidate.priority === effectivePriority); + + // A non-empty higher-precedence source is what the app/CLI will consume. + // Never fall through to a lower-precedence valid key when that source is malformed. + if (effectiveCandidates.some((item) => !item.decoded)) { + return { + found: false, + source: effectiveCandidates[0]!.candidate.source, + conflict: false, + candidateSources, + invalidSources: [...invalidSources].sort(), + }; + } + + const effectiveValid = effectiveCandidates.filter( + ( + item, + ): item is typeof item & { + decoded: { frontendApiHost: string; instanceType: "development" | "production" }; + } => item.decoded != null, + ); + const identities = new Set( + effectiveValid.map((item) => `${item.decoded.instanceType}:${item.decoded.frontendApiHost}`), + ); + const effective = effectiveValid[0]; + + if (identities.size > 1) { + diagnostics.push({ + code: "clerk.conflicting-publishable-keys", + severity: "error", + message: "Equally effective publishable-key sources point to different Clerk instances.", + remedy: "Remove the stale source or make the equally preferred values agree.", + evidence: effectiveValid.flatMap((item) => item.candidate.evidence), + }); + return { + found: true, + source: effective!.candidate.source, + conflict: true, + candidateSources, + invalidSources: [...invalidSources].sort(), + }; + } + + if (!effective) { + return { + found: false, + conflict: false, + candidateSources, + invalidSources: [...invalidSources].sort(), + }; + } + return { + found: true, + conflict: false, + source: effective.candidate.source, + frontendApiHost: effective.decoded.frontendApiHost, + instanceType: effective.decoded.instanceType, + candidateSources, + invalidSources: [...invalidSources].sort(), + }; +} + +async function localPackageIsClerk(root: string, packagePath: string): Promise { + const manifestPath = resolve(packagePath, "Package.swift"); + if (!(await pathIsSafelyWithinIOSRoot(root, manifestPath))) return false; + const manifest = Bun.file(manifestPath); + if (!(await manifest.exists()) || manifest.size > 1_000_000) return false; + try { + const source = await manifest.text(); + return /\b(?:ClerkKit|ClerkKitUI)\b/.test(source); + } catch { + return false; + } +} + +async function inspectPackageReferences( + root: string, + projectPath: string, + projectObject: PbxObject, + objects: PbxObjects, +): Promise { + const packageIds = new Set(asStringArray(projectObject.packageReferences)); + // Some modern/local project layouts leave the project-level list partial. + // Preserve any package object the target graph can still reference. + for (const [id, object] of Object.entries(objects)) { + if ( + object.isa === "XCRemoteSwiftPackageReference" || + object.isa === "XCLocalSwiftPackageReference" + ) { + packageIds.add(id); + } + } + + const packages: IOSPackageReference[] = []; + for (const objectId of [...packageIds].sort()) { + const object = objects[objectId]; + if (object?.isa === "XCRemoteSwiftPackageReference") { + const rawRepository = asString(object.repositoryURL); + if (!rawRepository) continue; + const repository = sanitizeRepositoryURL(rawRepository); + packages.push({ + kind: "remote", + objectId, + repository, + requirement: canonicalRequirement(object.requirement), + isClerk: isClerkIOSRepository(repository), + }); + } else if (object?.isa === "XCLocalSwiftPackageReference") { + const relativePath = asString(object.relativePath); + if (!relativePath) continue; + const absolutePath = resolve(dirname(projectPath), relativePath); + const safelyLocal = await pathIsSafelyWithinIOSRoot(root, absolutePath); + packages.push({ + kind: "local", + objectId, + path: safelyLocal ? relativeIOSPath(root, absolutePath) : absolutePath, + isClerk: safelyLocal && (await localPackageIsClerk(root, absolutePath)), + }); + } + } + return packages; +} + +function targetProductState( + targetObject: PbxObject, + objects: PbxObjects, + productName: "ClerkKit" | "ClerkKitUI", +): { state: IOSProductLinkState; productIds: string[]; packageIds: string[] } { + const targetProductIds = asStringArray(targetObject.packageProductDependencies); + const matchingProductIds = targetProductIds.filter((id) => { + const product = objects[id]; + return ( + product?.isa === "XCSwiftPackageProductDependency" && product.productName === productName + ); + }); + if (matchingProductIds.length === 0) { + return { state: "absent", productIds: [], packageIds: [] }; + } + + const linkedProductIds = new Set(); + for (const phaseId of asStringArray(targetObject.buildPhases)) { + const phase = objects[phaseId]; + if (phase?.isa !== "PBXFrameworksBuildPhase") continue; + for (const buildFileId of asStringArray(phase.files)) { + const buildFile = objects[buildFileId]; + if (!buildFile || !buildFileIOSApplicability(buildFile).applies) continue; + const productRef = asString(buildFile.productRef); + if (productRef) linkedProductIds.add(productRef); + } + } + + const packageIds = matchingProductIds + .map((id) => asString(objects[id]?.package)) + .filter((id): id is string => id != null); + return { + state: matchingProductIds.some((id) => linkedProductIds.has(id)) ? "linked" : "declared", + productIds: matchingProductIds, + packageIds, + }; +} + +function inspectTargetPackages( + root: string, + projectPath: string, + targetName: string, + targetObject: PbxObject, + objects: PbxObjects, + packages: IOSPackageReference[], + diagnostics: IOSDiagnostic[], +): IOSClerkPackageState { + const clerkKit = targetProductState(targetObject, objects, "ClerkKit"); + const clerkKitUI = targetProductState(targetObject, objects, "ClerkKitUI"); + const packageById = new Map(packages.map((item) => [item.objectId, item])); + const productPackageIds = [...clerkKit.packageIds, ...clerkKitUI.packageIds]; + const attributed = productPackageIds + .map((id) => packageById.get(id)) + .filter((item): item is IOSPackageReference => item?.isClerk === true); + const declaredClerkPackage = packages.find((item) => item.isClerk); + const hasClerkProduct = clerkKit.state !== "absent" || clerkKitUI.state !== "absent"; + + let packageKind: IOSClerkPackageState["package"] = "absent"; + if (attributed[0]) packageKind = attributed[0].kind; + else if (declaredClerkPackage) packageKind = declaredClerkPackage.kind; + else if (hasClerkProduct) { + packageKind = "unattributed"; + diagnostics.push({ + code: "clerk.package-unattributed", + severity: "warning", + message: `${targetName} declares a Clerk product without an attributable clerk-ios package reference. This can be valid for a workspace-local package, but should be reviewed.`, + evidence: [...clerkKit.productIds, ...clerkKitUI.productIds].map((objectId) => ({ + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId, + })), + }); + } + + return { package: packageKind, clerkKit: clerkKit.state, clerkKitUI: clerkKitUI.state }; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +async function inspectEntitlements( + root: string, + absolutePath: string, + evidence: IOSSourceEvidence[], + diagnostics: IOSDiagnostic[], +): Promise { + const relativePath = relativeIOSPath(root, absolutePath); + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + diagnostics.push({ + code: "xcode.missing-entitlements", + severity: "warning", + message: `The configured entitlements file does not exist: ${relativePath}`, + remedy: "Create the file in Xcode or update CODE_SIGN_ENTITLEMENTS.", + evidence, + }); + return undefined; + } + + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + throw new Error("binary plist"); + } + const parsed: unknown = plist.parse(new TextDecoder().decode(bytes)); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + + const applicationIdentifier = asString(parsed["application-identifier"]); + return { + path: relativePath, + associatedDomains: stringArray(parsed["com.apple.developer.associated-domains"]).sort(), + unresolvedAssociatedDomains: [], + applicationIdentifier, + teamIdentifier: asString(parsed["com.apple.developer.team-identifier"]), + signInWithApple: stringArray(parsed["com.apple.developer.applesignin"]).length > 0, + }; + } catch { + diagnostics.push({ + code: "xcode.unreadable-entitlements", + severity: "warning", + message: `Could not inspect entitlements at ${relativePath}. Only XML plist entitlements are read in portable mode.`, + remedy: "Open the file in Xcode and save it as XML, then rerun the inspector.", + evidence, + }); + return undefined; + } +} + +async function attachEntitlements( + root: string, + projectPath: string, + configurations: IOSBuildConfiguration[], + settingsByConfiguration: Map>, + diagnostics: IOSDiagnostic[], +): Promise { + const cache = new Map(); + for (const configuration of configurations) { + if (configuration.entitlementsPath.state !== "resolved") continue; + const absolutePath = resolveEntitlementsAbsolutePath( + root, + projectPath, + configuration.entitlementsPath, + ); + if (!absolutePath) { + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS outside the inspected root.`, + evidence: configuration.entitlementsPath.evidence, + }); + continue; + } + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS through a path outside the inspected root.`, + evidence: configuration.entitlementsPath.evidence, + }); + continue; + } + if (!cache.has(absolutePath)) { + cache.set( + absolutePath, + await inspectEntitlements( + root, + absolutePath, + configuration.entitlementsPath.evidence, + diagnostics, + ), + ); + } + const entitlements = cache.get(absolutePath); + if (!entitlements) continue; + + const settings = settingsByConfiguration.get(configuration.name) ?? {}; + const resolvedAssociatedDomains: string[] = []; + const unresolvedAssociatedDomains: string[] = []; + for (const domain of entitlements.associatedDomains) { + const expanded = expandEntitlementDomain( + domain, + settings, + configuration.bundleIdentifier.state === "resolved" + ? configuration.bundleIdentifier.value + : undefined, + ); + if (expanded) resolvedAssociatedDomains.push(expanded); + else unresolvedAssociatedDomains.push(domain); + } + if (unresolvedAssociatedDomains.length > 0) { + diagnostics.push({ + code: "xcode.unresolved-build-setting", + severity: "warning", + message: `${configuration.name} has associated-domain values with unresolved build settings.`, + remedy: + "Resolve the variables in the entitlements configuration before relying on domain checks.", + evidence: configuration.entitlementsPath.evidence, + }); + } + + const applicationIdentifier = entitlements.applicationIdentifier; + const prefixMatch = /^([A-Z0-9]{10})\.(.+)$/.exec(applicationIdentifier ?? ""); + const literalAppIdentifierPrefix = + prefixMatch && + configuration.bundleIdentifier.state === "resolved" && + prefixMatch[2] === configuration.bundleIdentifier.value + ? prefixMatch[1] + : undefined; + configuration.entitlements = { + ...entitlements, + associatedDomains: resolvedAssociatedDomains.sort(), + unresolvedAssociatedDomains: unresolvedAssociatedDomains.sort(), + ...(literalAppIdentifierPrefix ? { literalAppIdentifierPrefix } : {}), + }; + } +} + +function expandEntitlementDomain( + raw: string, + settings: Record, + bundleIdentifier: string | undefined, +): string | undefined { + const variable = /\$\(([^)]+)\)|\$\{([^}]+)\}/g; + const builtins: Record = { + CFBundleIdentifier: bundleIdentifier, + PRODUCT_BUNDLE_IDENTIFIER: bundleIdentifier, + }; + const resolving = new Set(); + const expand = (value: string, depth: number): string | undefined => { + if (depth > 20) return undefined; + let unresolved = false; + variable.lastIndex = 0; + const expanded = value.replace(variable, (_match, parenthesized, braced) => { + const name = String(parenthesized ?? braced); + if (name.includes(":") || resolving.has(name)) { + unresolved = true; + return ""; + } + const replacement = settings[name] ?? builtins[name]; + if (replacement == null) { + unresolved = true; + return ""; + } + resolving.add(name); + const nested = expand(replacement, depth + 1); + resolving.delete(name); + if (nested == null) unresolved = true; + return nested ?? ""; + }); + variable.lastIndex = 0; + return unresolved || variable.test(expanded) ? undefined : expanded; + }; + + const expanded = expand(raw, 0)?.trim(); + if (!expanded || /pk_(?:test|live)_/i.test(expanded)) return undefined; + return expanded; +} + +function normalizeSynchronizedPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); +} + +function synchronizedExclusions( + group: PbxObject, + targetId: string, + relevantPhaseIds: Set, + objects: PbxObjects, +): Set { + const excluded = new Set(); + for (const exceptionId of asStringArray(group.exceptions)) { + const exception = objects[exceptionId]; + const appliesToTarget = + exception?.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet" && + asString(exception.target) === targetId; + const appliesToPhase = + exception?.isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet" && + relevantPhaseIds.has(asString(exception.buildPhase) ?? ""); + if (!appliesToTarget && !appliesToPhase) continue; + + for (const path of asStringArray(exception.membershipExceptions)) { + excluded.add(normalizeSynchronizedPath(path)); + } + if (isRecord(exception.platformFiltersByRelativePath)) { + for (const [path, filters] of Object.entries(exception.platformFiltersByRelativePath)) { + const platformFilters = stringArray(filters); + if ( + platformFilters.length > 0 && + !platformFilters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter)) + ) { + excluded.add(normalizeSynchronizedPath(path)); + } + } + } + } + return excluded; +} + +function synchronizedPathIsExcluded(path: string, excluded: Set): boolean { + return [...excluded].some( + (excludedPath) => path === excludedPath || path.startsWith(`${excludedPath}/`), + ); +} + +async function localSecretsForTarget(options: { + root: string; + projectPath: string; + groupRootDirectory: string; + targetId: string; + targetObject: PbxObject; + objects: PbxObjects; + parents: Map; +}): Promise { + const { root, projectPath, groupRootDirectory, targetId, targetObject, objects, parents } = + options; + const projectDirectory = dirname(projectPath); + const paths = new Set(); + const resourcePhaseIds = new Set( + asStringArray(targetObject.buildPhases).filter( + (phaseId) => objects[phaseId]?.isa === "PBXResourcesBuildPhase", + ), + ); + + for (const phaseId of resourcePhaseIds) { + const phase = objects[phaseId]; + if (phase?.isa !== "PBXResourcesBuildPhase") continue; + for (const buildFileId of asStringArray(phase.files)) { + const buildFile = objects[buildFileId]; + if (!buildFile || !buildFileIOSApplicability(buildFile).applies) continue; + const fileReference = asString(buildFile.fileRef); + if (!fileReference) continue; + const absolutePath = resolvePbxFilePath( + fileReference, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if ( + absolutePath?.endsWith(`${sep}LocalSecrets.plist`) && + (await pathIsSafelyWithinIOSRoot(root, absolutePath)) + ) { + paths.add(absolutePath); + } + } + } + + for (const groupId of asStringArray(targetObject.fileSystemSynchronizedGroups)) { + const group = objects[groupId]; + if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") continue; + const groupPath = resolvePbxFilePath( + groupId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!groupPath || !(await pathIsSafelyWithinIOSRoot(root, groupPath))) continue; + + const discovered: string[] = []; + await collectLocalSecretsPlists(root, groupPath, discovered); + const excluded = synchronizedExclusions(group, targetId, resourcePhaseIds, objects); + for (const absolutePath of discovered) { + const relativePath = relative(groupPath, absolutePath).split(sep).join("/"); + if (!synchronizedPathIsExcluded(relativePath, excluded)) paths.add(absolutePath); + } + } + + return [...paths].sort(); +} + +async function collectSwiftFiles( + root: string, + directory: string, + groupRoot: string, + excluded: Set, + files: Map, + state: { complete: boolean }, + depth = 0, +): Promise { + if (depth > MAX_SOURCE_DEPTH || files.size >= MAX_SOURCE_FILES) { + state.complete = false; + return; + } + if (!(await pathIsSafelyWithinIOSRoot(root, directory))) { + state.complete = false; + return; + } + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + state.complete = false; + return; + } + + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (files.size >= MAX_SOURCE_FILES) { + state.complete = false; + return; + } + const absolutePath = resolve(directory, entry.name); + const pathFromGroup = relative(groupRoot, absolutePath).split(sep).join("/"); + if (synchronizedPathIsExcluded(pathFromGroup, excluded)) { + continue; + } + if (entry.isDirectory()) { + if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { + await collectSwiftFiles(root, absolutePath, groupRoot, excluded, files, state, depth + 1); + } + } else if (entry.isFile() && extname(entry.name) === ".swift") { + files.set(absolutePath, { absolutePath, relativePath: relativeIOSPath(root, absolutePath) }); + } else if (entry.isSymbolicLink() && extname(entry.name) === ".swift") { + state.complete = false; + } + } +} + +async function sourceFilesForTarget(options: { + root: string; + projectPath: string; + groupRootDirectory: string; + targetId: string; + targetObject: PbxObject; + objects: PbxObjects; + parents: Map; + diagnostics: IOSDiagnostic[]; +}): Promise<{ + files: Array<{ absolutePath: string; relativePath: string }>; + complete: boolean; +}> { + const { + root, + projectPath, + groupRootDirectory, + targetId, + targetObject, + objects, + parents, + diagnostics, + } = options; + const projectDirectory = dirname(projectPath); + const files = new Map(); + const state = { complete: true }; + const projectEvidencePath = relativeIOSPath(root, resolve(projectPath, "project.pbxproj")); + const reportDangling = (objectId: string, message: string): void => { + state.complete = false; + diagnostics.push({ + code: "xcode.dangling-reference", + severity: "warning", + message, + evidence: [{ path: projectEvidencePath, objectId }], + }); + }; + for (const phaseId of asStringArray(targetObject.buildPhases)) { + if (!objects[phaseId]) { + reportDangling(phaseId, `Target ${targetId} contains a dangling build phase reference.`); + } + } + const sourcePhaseIds = new Set( + asStringArray(targetObject.buildPhases).filter( + (phaseId) => objects[phaseId]?.isa === "PBXSourcesBuildPhase", + ), + ); + + for (const phaseId of sourcePhaseIds) { + const phase = objects[phaseId]; + if (phase?.isa !== "PBXSourcesBuildPhase") continue; + for (const buildFileId of asStringArray(phase.files)) { + const buildFile = objects[buildFileId]; + if (!buildFile) { + reportDangling( + buildFileId, + `Sources phase ${phaseId} contains a dangling build-file reference.`, + ); + continue; + } + const applicability = buildFileIOSApplicability(buildFile); + if (!applicability.applies) { + if (!applicability.recognized) state.complete = false; + continue; + } + const fileReference = asString(buildFile.fileRef); + if (!fileReference) { + reportDangling( + buildFileId, + `Sources phase ${phaseId} contains a build file with no file reference.`, + ); + continue; + } + if (!objects[fileReference]) { + reportDangling( + fileReference, + `Sources phase ${phaseId} contains a dangling file reference.`, + ); + continue; + } + const absolutePath = resolvePbxFilePath( + fileReference, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!absolutePath) { + if (extname(asString(objects[fileReference]?.path) ?? "") === ".swift") { + state.complete = false; + } + continue; + } + if (extname(absolutePath) !== ".swift") continue; + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + state.complete = false; + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `Skipped Swift source outside the inspected root: ${absolutePath}`, + evidence: [{ path: absolutePath, objectId: fileReference }], + }); + continue; + } + files.set(absolutePath, { absolutePath, relativePath: relativeIOSPath(root, absolutePath) }); + } + } + + for (const groupId of asStringArray(targetObject.fileSystemSynchronizedGroups)) { + const group = objects[groupId]; + if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") { + reportDangling( + groupId, + `Target ${targetId} contains an invalid synchronized-group reference.`, + ); + continue; + } + const groupPath = resolvePbxFilePath( + groupId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!groupPath) { + state.complete = false; + continue; + } + if (!(await pathIsSafelyWithinIOSRoot(root, groupPath))) { + state.complete = false; + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `Skipped synchronized source group outside the inspected root: ${groupPath}`, + evidence: [{ path: groupPath, objectId: groupId }], + }); + continue; + } + + const excluded = synchronizedExclusions(group, targetId, sourcePhaseIds, objects); + await collectSwiftFiles(root, groupPath, groupPath, excluded, files, state); + } + + if (files.size === 0 || !state.complete) { + diagnostics.push({ + code: "xcode.incomplete-source-membership", + severity: "info", + message: + files.size === 0 + ? `No Swift source membership could be resolved for ${asString(targetObject.name) ?? targetId}; source-level Clerk checks may be incomplete.` + : `Swift source membership for ${asString(targetObject.name) ?? targetId} was only partially inspected; absence checks are advisory.`, + evidence: [ + { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: targetId, + }, + ], + }); + } + + return { + files: [...files.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath)), + complete: state.complete, + }; +} + +async function parseProject( + root: string, + projectPath: string, + requestedTarget?: string, +): Promise { + const projectRelativePath = relativeIOSPath(root, projectPath); + const pbxprojPath = resolve(projectPath, "project.pbxproj"); + const pbxprojRelativePath = relativeIOSPath(root, pbxprojPath); + const diagnostics: IOSDiagnostic[] = []; + const emptyInspection = (objectVersion?: string): IOSProjectInspection => ({ + path: projectRelativePath, + pbxprojPath: pbxprojRelativePath, + objectVersion, + packages: [], + appTargetIds: [], + diagnostics, + }); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) { + diagnostics.push({ + code: "xcode.external-path", + severity: "error", + message: `${projectRelativePath} resolves project.pbxproj outside the inspected root.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; + } + const file = Bun.file(pbxprojPath); + if (!(await file.exists())) { + diagnostics.push({ + code: "xcode.missing-project-file", + severity: "error", + message: `${projectRelativePath} does not contain project.pbxproj.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; + } + if (file.size > MAX_PBXPROJ_BYTES) { + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + message: `${pbxprojRelativePath} is too large to inspect safely.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; + } + + let archive: Record; + try { + const parsed: unknown = parsePbxProject(await readFile(pbxprojPath, "utf8")); + if (!isRecord(parsed)) throw new Error("invalid project root"); + archive = parsed; + } catch (error) { + const parserLocation = + error instanceof Error + ? error.message.match(/\b(?:line|column|position)\s+\d+(?::\d+)?/i)?.[0] + : undefined; + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + // Parser messages can quote the surrounding pbxproj token. Do not echo + // arbitrary project content because shell phases sometimes hold secrets. + message: `Could not parse ${pbxprojRelativePath}${parserLocation ? ` (${parserLocation})` : ""}.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; + } + + const objectVersion = asString(archive.objectVersion); + const objects = normalizeObjects(archive.objects); + if (!objects) { + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + message: `${pbxprojRelativePath} has no readable Xcode object graph.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { + inspection: emptyInspection(objectVersion), + appTargets: [], + appTargetCandidates: [], + diagnostics, + }; + } + + const rootObjectId = asString(archive.rootObject); + const projectObject = + (rootObjectId ? objects[rootObjectId] : undefined) ?? + Object.values(objects).find((object) => object.isa === "PBXProject"); + if (projectObject?.isa !== "PBXProject") { + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + message: `${pbxprojRelativePath} has no PBXProject root object.`, + evidence: [{ path: pbxprojRelativePath }], + }); + return { + inspection: emptyInspection(objectVersion), + appTargets: [], + appTargetCandidates: [], + diagnostics, + }; + } + + const parents = buildPbxParentIndex(objects); + const groupRootDirectory = resolve( + dirname(projectPath), + asString(projectObject.projectDirPath) ?? "", + ); + const packages = await inspectPackageReferences(root, projectPath, projectObject, objects); + const appTargets: IOSAppTarget[] = []; + const appTargetCandidates: ParsedProject["appTargetCandidates"] = []; + const targetIds = asStringArray(projectObject.targets).sort(); + const sourceMemberships: IOSTargetSourceMembership[] = []; + const sourceMembershipById = new Map< + string, + { + files: Array<{ absolutePath: string; relativePath: string }>; + complete: boolean; + diagnostics: IOSDiagnostic[]; + } + >(); + + // Resolve every native target, not only application products. Source + // mutators use this hidden result to refuse files shared with extensions, + // tests, or another app target while the public inspection JSON stays + // semantic and compact. + for (const targetId of targetIds) { + const targetObject = objects[targetId]; + if (targetObject?.isa !== "PBXNativeTarget") continue; + const membershipDiagnostics: IOSDiagnostic[] = []; + const membership = await sourceFilesForTarget({ + root, + projectPath, + groupRootDirectory, + targetId, + targetObject, + objects, + parents, + diagnostics: membershipDiagnostics, + }); + sourceMembershipById.set(targetId, { ...membership, diagnostics: membershipDiagnostics }); + sourceMemberships.push({ + targetId, + targetName: asString(targetObject.name) ?? targetId, + projectPath: projectRelativePath, + files: membership.files, + complete: membership.complete, + }); + } + + for (const targetId of targetIds) { + const targetObject = objects[targetId]; + if (targetObject?.isa !== "PBXNativeTarget" || targetObject.productType !== APP_PRODUCT_TYPE) { + continue; + } + const targetName = asString(targetObject.name) ?? targetId; + const configurationDiagnostics: IOSDiagnostic[] = []; + const targetConfigurations = await inspectTargetBuildConfigurations({ + root, + projectPath, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics: configurationDiagnostics, + }); + if ( + targetConfigurations.length > 0 && + !targetConfigurations.some((configuration) => configuration.isIOS) + ) { + continue; + } + appTargetCandidates.push({ + targetId, + targetName, + projectPath: projectRelativePath, + }); + if (requestedTarget && requestedTarget !== targetId && requestedTarget !== targetName) { + continue; + } + diagnostics.push(...configurationDiagnostics); + + const configurations = targetConfigurations.map((configuration) => configuration.model); + await attachEntitlements( + root, + projectPath, + configurations, + new Map( + targetConfigurations.map((configuration) => [ + configuration.model.name, + configuration.settings, + ]), + ), + diagnostics, + ); + addBuildSettingConflictDiagnostics(targetName, configurations, diagnostics); + const targetSources = sourceMembershipById.get(targetId) ?? { + files: [], + complete: false, + diagnostics: [], + }; + diagnostics.push(...targetSources.diagnostics); + + const swiftInspection = + targetSources.files.length > 0 + ? await inspectSwiftSources(targetSources.files, { + membershipComplete: targetSources.complete, + }) + : emptySwiftInspection(); + if (targetSources.complete && !swiftInspection.evidenceComplete) { + diagnostics.push({ + code: "xcode.incomplete-source-membership", + severity: "warning", + message: `One or more Swift members of ${targetName} could not be read safely; absence checks are advisory.`, + evidence: [ + { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: targetId, + }, + ], + }); + } + + const targetLocalSecrets = await localSecretsForTarget({ + root, + projectPath, + groupRootDirectory, + targetId, + targetObject, + objects, + parents, + }); + const appTarget: IOSAppTarget = { + id: targetId, + name: targetName, + productName: asString(targetObject.productName), + projectPath: projectRelativePath, + configurations, + packages: inspectTargetPackages( + root, + projectPath, + targetName, + targetObject, + objects, + packages, + diagnostics, + ), + swift: swiftInspection, + runtimeKeySinks: targetLocalSecrets.map((path) => ({ + kind: "local-secrets-plist" as const, + path: relativeIOSPath(root, path), + })), + }; + appTargets.push(appTarget); + } + + appTargets.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + appTargetCandidates.sort( + (a, b) => a.targetName.localeCompare(b.targetName) || a.targetId.localeCompare(b.targetId), + ); + return { + inspection: { + path: projectRelativePath, + pbxprojPath: pbxprojRelativePath, + objectVersion, + packages, + appTargetIds: appTargetCandidates.map((target) => target.targetId), + diagnostics, + }, + appTargets, + appTargetCandidates, + diagnostics, + sourceMemberships, + }; +} + +function selectTarget( + candidates: ParsedProject["appTargetCandidates"], + requestedTarget: string | undefined, + diagnostics: IOSDiagnostic[], +): IOSTargetSelection { + if (requestedTarget) { + const matches = candidates.filter( + (candidate) => + candidate.targetId === requestedTarget || candidate.targetName === requestedTarget, + ); + const match = matches[0]; + if (matches.length === 1 && match) return { state: "selected", ...match }; + if (matches.length > 1) { + const matchedByObjectId = matches.every( + (candidate) => candidate.targetId === requestedTarget, + ); + diagnostics.push({ + code: "xcode.ambiguous-app-target", + severity: "error", + message: matchedByObjectId + ? `Target object ID "${requestedTarget}" exists in more than one project.` + : `Target name "${requestedTarget}" exists in more than one project.`, + remedy: matchedByObjectId + ? "Run the inspector from the directory containing only the intended project." + : "Rerun with --target .", + evidence: matches.map((candidate) => ({ + path: candidate.projectPath, + objectId: candidate.targetId, + })), + }); + return { state: "ambiguous", candidates: matches }; + } + + diagnostics.push({ + code: "xcode.target-not-found", + severity: "error", + message: `No iOS application target matches "${requestedTarget}".`, + remedy: "Choose one of the reported target names or IDs.", + evidence: candidates.map((candidate) => ({ + path: candidate.projectPath, + objectId: candidate.targetId, + })), + }); + return { + state: "not-found", + requested: requestedTarget, + candidates: candidates.map((candidate) => `${candidate.targetName} (${candidate.targetId})`), + }; + } + + const onlyCandidate = candidates[0]; + if (candidates.length === 1 && onlyCandidate) { + return { state: "selected", ...onlyCandidate }; + } + if (candidates.length === 0) { + diagnostics.push({ + code: "xcode.no-ios-app-target", + severity: "error", + message: "No iOS application target was found.", + remedy: "Run from an iOS app project, or pass --framework ios from its project root.", + evidence: [], + }); + return { state: "none" }; + } + + diagnostics.push({ + code: "xcode.ambiguous-app-target", + severity: "error", + message: `Found ${candidates.length} iOS application targets; none was selected automatically.`, + remedy: "Rerun with --target .", + evidence: candidates.map((candidate) => ({ + path: candidate.projectPath, + objectId: candidate.targetId, + })), + }); + return { state: "ambiguous", candidates }; +} + +async function detectGeneratedProject( + root: string, +): Promise<{ kind: "xcodegen" | "tuist"; path: string } | null> { + const xcodeGenPath = resolve(root, "project.yml"); + if ( + (await pathIsSafelyWithinIOSRoot(root, xcodeGenPath)) && + (await Bun.file(xcodeGenPath).exists()) + ) { + return { kind: "xcodegen", path: "project.yml" }; + } + for (const path of ["Project.swift", "Workspace.swift", "Tuist/ProjectDescriptionHelpers"]) { + const absolutePath = resolve(root, path); + if ( + (await pathIsSafelyWithinIOSRoot(root, absolutePath)) && + (await Bun.file(absolutePath).exists()) + ) { + return { kind: "tuist", path }; + } + } + return null; +} + +export async function inspectIOSProject( + rootInput: string, + options: { target?: string; exhaustiveContainerDiscovery?: boolean } = {}, +): Promise { + const invocationPath = resolve(rootInput); + const root = invocationPath.endsWith(".xcodeproj") + ? dirname(invocationPath) + : invocationPath.endsWith(".xcworkspace") + ? dirname(invocationPath).endsWith(".xcodeproj") + ? dirname(dirname(invocationPath)) + : dirname(invocationPath) + : invocationPath; + const diagnostics: IOSDiagnostic[] = []; + const discovered = await discoverIOSContainers(invocationPath, { + exhaustive: options.exhaustiveContainerDiscovery === true, + }); + const projectPaths = new Set(discovered.projectPaths); + const workspaces = []; + + for (const workspacePath of discovered.workspacePaths) { + const workspace = await inspectWorkspace(root, workspacePath); + workspaces.push(workspace.inspection); + for (const projectPath of workspace.localProjectPaths) projectPaths.add(projectPath); + } + + if (projectPaths.size === 0) { + diagnostics.push({ + code: "xcode.no-project", + severity: "error", + message: "No .xcodeproj was found in the inspected root.", + remedy: "Run this command from the directory containing your iOS project.", + evidence: [], + }); + } + + const projects: IOSProjectInspection[] = []; + const appTargets: IOSAppTarget[] = []; + const appTargetCandidates: ParsedProject["appTargetCandidates"] = []; + const sourceMemberships: IOSTargetSourceMembership[] = []; + for (const projectPath of [...projectPaths].sort()) { + const parsed = await parseProject(root, projectPath, options.target); + projects.push(parsed.inspection); + appTargets.push(...parsed.appTargets); + appTargetCandidates.push(...parsed.appTargetCandidates); + sourceMemberships.push(...(parsed.sourceMemberships ?? [])); + diagnostics.push(...parsed.diagnostics); + } + if (options.exhaustiveContainerDiscovery === true && !discovered.complete) { + for (const membership of sourceMemberships) membership.complete = false; + } + appTargets.sort( + (a, b) => + a.projectPath.localeCompare(b.projectPath) || + a.name.localeCompare(b.name) || + a.id.localeCompare(b.id), + ); + appTargetCandidates.sort( + (a, b) => + a.projectPath.localeCompare(b.projectPath) || + a.targetName.localeCompare(b.targetName) || + a.targetId.localeCompare(b.targetId), + ); + + const generatedProjectMarker = await detectGeneratedProject(root); + if (generatedProjectMarker) { + diagnostics.push({ + code: "xcode.generated-project", + severity: "warning", + message: `This appears to be a ${generatedProjectMarker.kind === "xcodegen" ? "XcodeGen" : "Tuist"} project. Future automated setup must update the source manifest, not generated project.pbxproj output.`, + evidence: [{ path: generatedProjectMarker.path }], + }); + } + + const selection = selectTarget(appTargetCandidates, options.target, diagnostics); + const selectedAppTarget = + selection.state === "selected" + ? appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + const inlinePublishableKeyCandidates: PublishableKeyCandidate[] = + selectedAppTarget?.swift.configureCalls + .filter((call) => call.publishableKeyWiring === "inline-literal") + .map((call) => ({ + source: call.path, + evidence: [{ path: call.path, keyPath: "Clerk.configure(publishableKey:)" }], + priority: 0, + ...(call.inlinePublishableKey?.state === "valid" + ? { + decoded: { + frontendApiHost: call.inlinePublishableKey.frontendApiHost, + instanceType: call.inlinePublishableKey.instanceType, + }, + } + : { invalid: true as const }), + })) ?? []; + const localPublishableKeyInspection = await inspectLocalPublishableKeys( + root, + selection, + selectedAppTarget?.runtimeKeySinks.map((sink) => resolve(root, sink.path)) ?? [], + [ + ...(selection.state === "selected" ? [resolve(root, selection.projectPath)] : []), + ...discovered.workspacePaths, + ], + inlinePublishableKeyCandidates, + diagnostics, + ); + const result: IOSProjectInspectionResult = { + schemaVersion: 1, + platform: "ios", + root, + workspaces: workspaces.sort((a, b) => a.path.localeCompare(b.path)), + projects: projects.sort((a, b) => a.path.localeCompare(b.path)), + appTargets, + selection, + localPublishableKey: localPublishableKeyInspection, + generatedProject: generatedProjectMarker?.kind ?? null, + diagnostics, + }; + sourceMembershipByInspection.set(result, sourceMemberships); + return result; +} + +/** + * Returns the exact source-membership result used by the iOS semantic + * inspector without adding source paths to the serializable inspection JSON. + * This is intended for strict source mutators that must prove a non-Clerk + * Swift file belongs to one selected application target. + */ +export async function inspectIOSSourceMembership( + rootInput: string, +): Promise { + const inspection = await inspectIOSProject(rootInput, { + exhaustiveContainerDiscovery: true, + }); + return sourceMembershipByInspection.get(inspection) ?? []; +} diff --git a/packages/cli-core/src/commands/init/ios/pbx.test.ts b/packages/cli-core/src/commands/init/ios/pbx.test.ts new file mode 100644 index 000000000..027238263 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/pbx.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + buildPbxParentIndex, + isClerkIOSRepository, + resolvePbxFilePath, + sanitizeRepositoryURL, + type PbxObjects, +} from "./pbx.ts"; + +describe("resolvePbxFilePath", () => { + test("does not treat a pathless group's display name as a directory", () => { + const objects: PbxObjects = { + root: { + isa: "PBXGroup", + children: ["logical"], + sourceTree: "", + }, + logical: { + isa: "PBXGroup", + children: ["file"], + name: "Navigator Label", + sourceTree: "", + }, + file: { + isa: "PBXFileReference", + name: "Display Name.swift", + path: "Sources/App.swift", + sourceTree: "", + }, + }; + const projectDirectory = join("/", "tmp", "Example"); + + expect( + resolvePbxFilePath("file", objects, buildPbxParentIndex(objects), projectDirectory), + ).toBe(join(projectDirectory, "Sources", "App.swift")); + }); + + test("does not guess a path from a pathless file reference's display name", () => { + const objects: PbxObjects = { + root: { + isa: "PBXGroup", + children: ["file"], + sourceTree: "", + }, + file: { + isa: "PBXFileReference", + name: "Display Name.swift", + sourceTree: "", + }, + }; + + expect( + resolvePbxFilePath("file", objects, buildPbxParentIndex(objects), "/tmp/Example"), + ).toBeUndefined(); + }); + + test("uses projectDirPath only for group-relative paths, not SOURCE_ROOT", () => { + const objects: PbxObjects = { + main: { isa: "PBXGroup", children: ["group", "sourceRootFile"], sourceTree: "" }, + group: { + isa: "PBXGroup", + children: ["groupFile"], + path: "Sources", + sourceTree: "", + }, + groupFile: { isa: "PBXFileReference", path: "App.swift", sourceTree: "" }, + sourceRootFile: { + isa: "PBXFileReference", + path: "Root.swift", + sourceTree: "SOURCE_ROOT", + }, + }; + const parents = buildPbxParentIndex(objects); + + expect( + resolvePbxFilePath( + "groupFile", + objects, + parents, + "/tmp/Example", + "/tmp/Example/ActualProjectRoot", + ), + ).toBe("/tmp/Example/ActualProjectRoot/Sources/App.swift"); + expect( + resolvePbxFilePath( + "sourceRootFile", + objects, + parents, + "/tmp/Example", + "/tmp/Example/ActualProjectRoot", + ), + ).toBe("/tmp/Example/Root.swift"); + }); +}); + +describe("sanitizeRepositoryURL", () => { + test("canonicalizes Clerk HTTPS and SCP-style repository URLs", () => { + expect(sanitizeRepositoryURL("https://token@example.com/clerk/clerk-ios.git?key=secret")).toBe( + "https://example.com/clerk/clerk-ios", + ); + expect(sanitizeRepositoryURL("git@github.com:clerk/clerk-ios.git")).toBe( + "ssh://github.com/clerk/clerk-ios", + ); + expect(isClerkIOSRepository("git@github.com:clerk/clerk-ios.git")).toBe(true); + }); + + test("does not expose unsupported local or malformed repository values", () => { + expect(sanitizeRepositoryURL("file:///Users/alice/private/clerk-ios")).toBe( + "file://", + ); + expect(sanitizeRepositoryURL("not valid token@example.com/path")).toBe( + "", + ); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/pbx.ts b/packages/cli-core/src/commands/init/ios/pbx.ts new file mode 100644 index 000000000..a9b73c753 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/pbx.ts @@ -0,0 +1,129 @@ +import { isAbsolute, resolve } from "node:path"; + +export type PbxObject = Record & { isa?: string }; +export type PbxObjects = Record; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asString(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return undefined; +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +export function asStringRecord(value: unknown): Record { + if (!isRecord(value)) return {}; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === "string" || typeof item === "number") { + result[key] = String(item); + } else if (Array.isArray(item)) { + result[key] = item.map(String).join(" "); + } + } + return result; +} + +export function buildPbxParentIndex(objects: PbxObjects): Map { + const parents = new Map(); + for (const [id, object] of Object.entries(objects)) { + for (const child of asStringArray(object.children)) { + if (!parents.has(child)) parents.set(child, id); + } + } + return parents; +} + +/** Resolve a group/file reference without invoking Xcode. */ +export function resolvePbxFilePath( + objectId: string, + objects: PbxObjects, + parents: Map, + projectDirectory: string, + groupRootDirectory: string = projectDirectory, + seen: Set = new Set(), +): string | undefined { + if (seen.has(objectId)) return undefined; + seen.add(objectId); + + const object = objects[objectId]; + if (!object) return undefined; + + const objectPath = asString(object.path); + // `name` is only an Xcode navigator display label. A pathless group is a + // logical group and contributes no filesystem component; a pathless file + // reference cannot be resolved without guessing. + if (objectPath == null && object.isa === "PBXFileReference") return undefined; + const rawPath = objectPath ?? ""; + const sourceTree = asString(object.sourceTree) ?? ""; + + if (sourceTree === "") { + return rawPath ? resolve(rawPath) : undefined; + } + if (isAbsolute(rawPath)) { + return resolve(rawPath); + } + if (sourceTree === "SOURCE_ROOT" || sourceTree === "") { + return resolve(projectDirectory, rawPath); + } + if (sourceTree !== "") return undefined; + + const parentId = parents.get(objectId); + if (!parentId) return resolve(groupRootDirectory, rawPath); + const parentPath = resolvePbxFilePath( + parentId, + objects, + parents, + projectDirectory, + groupRootDirectory, + seen, + ); + return parentPath ? resolve(parentPath, rawPath) : undefined; +} + +export function sanitizeRepositoryURL(repository: string): string { + const trimmed = repository.trim(); + const scpMatch = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/); + const scpHost = scpMatch?.[1]; + const scpPath = scpMatch?.[2]; + if (scpHost && scpPath && !trimmed.includes("://")) { + return `ssh://${scpHost.toLowerCase()}/${scpPath.replace(/^\/+/, "")}` + .replace(/\.git\/?$/i, "") + .replace(/\/$/, ""); + } + + try { + const url = new URL(trimmed); + if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol)) { + return `${url.protocol}//`; + } + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return `${url.protocol}//${url.host.toLowerCase()}${url.pathname}` + .replace(/\.git\/?$/i, "") + .replace(/\/$/, ""); + } catch { + const sanitized = trimmed + .replace(/[?#].*$/, "") + .replace(/\.git\/?$/i, "") + .replace(/\/$/, ""); + return /^[a-zA-Z0-9._/-]+$/.test(sanitized) ? sanitized : ""; + } +} + +export function isClerkIOSRepository(repository: string): boolean { + const canonical = sanitizeRepositoryURL(repository).toLowerCase(); + return ( + canonical === "https://github.com/clerk/clerk-ios" || + canonical === "ssh://github.com/clerk/clerk-ios" + ); +} diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts new file mode 100644 index 000000000..e2ede8771 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { + clerkKitUIInstallDecision, + hasIOSDirectConfigCompatibility, + shouldInstallClerkKitUI, + shouldPlanIOSDirectConfig, +} from "./products.ts"; +import type { IOSAppTarget, IOSProjectInspectionResult } from "./types.ts"; + +function target(): IOSAppTarget { + return { + id: "TARGET", + name: "MyApp", + projectPath: "MyApp.xcodeproj", + configurations: [], + packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, + runtimeKeySinks: [], + swift: { + sourceFilesScanned: 1, + evidenceComplete: true, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + localSecretsRuntimeBindings: [], + environmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent", + }, + }; +} + +function inspection(selected: IOSAppTarget): IOSProjectInspectionResult { + return { + schemaVersion: 1, + platform: "ios", + root: "/tmp/test", + workspaces: [], + projects: [], + appTargets: [selected], + selection: { + state: "selected", + targetId: selected.id, + targetName: selected.name, + projectPath: selected.projectPath, + }, + localPublishableKey: { + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }, + generatedProject: null, + diagnostics: [], + }; +} + +describe("shouldInstallClerkKitUI", () => { + test("defaults an untouched, fully inspected target to the prebuilt UI path", () => { + expect(shouldInstallClerkKitUI(target())).toBe(true); + }); + + test("upgrades a source-blank ClerkKit-only graph created by an earlier setup", () => { + const coreOnly = target(); + coreOnly.packages = { package: "remote", clerkKit: "linked", clerkKitUI: "absent" }; + expect(shouldInstallClerkKitUI(coreOnly)).toBe(true); + }); + + test("preserves a source-proven custom-flow target", () => { + const customSource = target(); + customSource.swift.importsClerkKit = [{ path: "MyApp/Auth.swift" }]; + customSource.swift.status = "partial"; + expect(shouldInstallClerkKitUI(customSource)).toBe(false); + }); + + test("honors existing ClerkKitUI source or product evidence", () => { + const imported = target(); + imported.swift.importsClerkKitUI = [{ path: "MyApp/Auth.swift" }]; + imported.swift.status = "partial"; + expect(shouldInstallClerkKitUI(imported)).toBe(true); + + const declared = target(); + declared.packages.clerkKitUI = "declared"; + expect(shouldInstallClerkKitUI(declared)).toBe(true); + }); + + test("does not infer a prebuilt default from incomplete source evidence", () => { + const incomplete = target(); + incomplete.swift.evidenceComplete = false; + incomplete.packages = { package: "remote", clerkKit: "linked", clerkKitUI: "absent" }; + expect(clerkKitUIInstallDecision(incomplete)).toBe("unknown"); + expect(shouldInstallClerkKitUI(incomplete)).toBe(false); + + incomplete.swift.importsClerkKit = [{ path: "MyApp/Visible.swift" }]; + expect(clerkKitUIInstallDecision(incomplete)).toBe("unknown"); + expect(shouldInstallClerkKitUI(incomplete)).toBe(false); + }); +}); + +describe("direct configuration compatibility", () => { + test.each(["local-secrets-loader", "process-info-environment"] as const)( + "preserves an existing %s configure route", + (publishableKeyWiring) => { + const selected = target(); + selected.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring, + startupBinding: "app-init", + ...(publishableKeyWiring === "local-secrets-loader" + ? { localSecretsRuntimeBinding: "proven" as const } + : {}), + }, + ]; + const result = inspection(selected); + + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(true); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(false); + }, + ); + + test("preserves an enabled selected-target scheme key route", () => { + const selected = target(); + const result = inspection(selected); + result.localPublishableKey.candidateSources = [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + ]; + + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(true); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(false); + }); + + test("preserves a target-owned runtime key sink", () => { + const selected = target(); + selected.runtimeKeySinks = [{ kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }]; + const result = inspection(selected); + + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(true); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(false); + }); + + test("plans direct configuration for a fresh compatible target", () => { + const selected = target(); + const result = inspection(selected); + + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/products.ts b/packages/cli-core/src/commands/init/ios/products.ts new file mode 100644 index 000000000..86b9e3ddd --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/products.ts @@ -0,0 +1,59 @@ +import type { IOSAppTarget, IOSProjectInspectionResult } from "./types.ts"; + +export type ClerkKitUIInstallDecision = "prebuilt" | "core-only" | "unknown"; + +/** + * Chooses the prebuilt UI product for an explicitly UI-backed target or for a + * fully inspected target that has not begun a custom Clerk integration. A + * source-blank ClerkKit-only graph is upgraded because an earlier CLI release + * may have created it; source-proven custom targets remain core-only. + */ +export function clerkKitUIInstallDecision(target: IOSAppTarget): ClerkKitUIInstallDecision { + const hasUIIntent = + target.swift.importsClerkKitUI.length > 0 || target.packages.clerkKitUI !== "absent"; + if (hasUIIntent) return "prebuilt"; + + const hasCustomSourceIntent = target.swift.importsClerkKit.length > 0; + if (!target.swift.evidenceComplete) return "unknown"; + return hasCustomSourceIntent ? "core-only" : "prebuilt"; +} + +export function shouldInstallClerkKitUI(target: IOSAppTarget): boolean { + return clerkKitUIInstallDecision(target) === "prebuilt"; +} + +/** Existing runtime-key routes that direct source configuration must preserve. */ +export function hasIOSDirectConfigCompatibility( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, +): boolean { + const hasCompatibleConfigure = target.swift.configureCalls.some( + (call) => + call.publishableKeyWiring === "local-secrets-loader" || + call.publishableKeyWiring === "process-info-environment", + ); + const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => + source.endsWith(".xcscheme"), + ); + return hasCompatibleConfigure || hasEnabledSchemeKey || target.runtimeKeySinks.length > 0; +} + +/** + * Routes only the fresh/direct-literal Swift path to the source mutator. + * Existing LocalSecrets and ProcessInfo integrations remain compatibility + * paths and are never rewritten into a literal automatically. + */ +export function shouldPlanIOSDirectConfig( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + productDecision: ClerkKitUIInstallDecision = clerkKitUIInstallDecision(target), +): boolean { + if (hasIOSDirectConfigCompatibility(inspection, target)) return false; + + const hasInlineConfigure = target.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "inline-literal", + ); + return ( + hasInlineConfigure || target.swift.configureCalls.length > 0 || productDecision === "prebuilt" + ); +} diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts new file mode 100644 index 000000000..6c774125e --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -0,0 +1,879 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { inspectSwiftSources, sanitizeSwiftSource } from "./swift.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("sanitizeSwiftSource", () => { + test("removes nested comments and standard, multiline, and raw strings", () => { + const source = ` + // Clerk.configure(publishableKey: "fake") + /* outer /* @main */ AuthView() */ + let standard = "Clerk.configure()" + let multiline = """.environment(Clerk.shared)""" + let raw = ##"AuthView()"## + @main struct RealApp: App {} + `; + + const sanitized = sanitizeSwiftSource(source); + expect(sanitized).not.toContain("Clerk.configure"); + expect(sanitized).not.toContain("AuthView"); + expect(sanitized).not.toContain(".environment"); + expect(sanitized).toContain("@main struct RealApp"); + }); +}); + +describe("inspectSwiftSources", () => { + test("records real Clerk evidence without retaining key expressions", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import ClerkKitUI + @main struct AppMain: App { + init() { Clerk.configure(publishableKey: "pk_test_must-not-leak") } + var body: some Scene { + WindowGroup { + AuthView() + .environment(Clerk.shared) + .onOpenURL { url in + Task { try await Clerk.shared.handle(url) } + } + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.status).toBe("complete"); + expect(inspection.configureCalls).toEqual([ + { + path: "App.swift", + publishableKeyWiring: "inline-literal", + inlinePublishableKey: { state: "invalid" }, + startupBinding: "app-init", + }, + ]); + expect(inspection.environmentInjections).toEqual([{ path: "App.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); + expect(JSON.stringify(inspection)).not.toContain("must-not-leak"); + }); + + test("retains only decoded metadata for a valid inline publishable key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + await Bun.write( + path, + `import ClerkKit + @main struct AppMain: App { + init() { Clerk.configure(publishableKey: "${publishableKey}") } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.configureCalls).toEqual([ + { + path: "App.swift", + publishableKeyWiring: "inline-literal", + inlinePublishableKey: { + state: "valid", + frontendApiHost: "inline.clerk.example", + instanceType: "development", + }, + startupBinding: "app-init", + }, + ]); + expect(JSON.stringify(inspection)).not.toContain(publishableKey); + }); + + test("recognizes selective ClerkKit and ClerkKitUI imports", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const corePath = join(root, "Core.swift"); + const uiPath = join(root, "UI.swift"); + await Bun.write(corePath, "import class ClerkKit.Clerk\n"); + await Bun.write(uiPath, "import struct ClerkKitUI.AuthView\n"); + + const inspection = await inspectSwiftSources([ + { absolutePath: corePath, relativePath: "Core.swift" }, + { absolutePath: uiPath, relativePath: "UI.swift" }, + ]); + + expect(inspection.importsClerkKit).toEqual([{ path: "Core.swift" }]); + expect(inspection.importsClerkKitUI).toEqual([{ path: "UI.swift" }]); + expect(inspection.status).toBe("partial"); + }); + + test("classifies runtime key wiring without retaining expressions or values", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Configuration.swift"); + await Bun.write( + path, + `import ClerkKit + func configureFromLoad() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "pk_test_loader-secret") + } + func configureFromProperty() { + Clerk.configure(publishableKey: LocalSecrets.key) + } + func configureFromEnvironment() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "pk_test_environment-secret") + } + func configureFromUnrelatedEnvironment() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["ANALYTICS_KEY"] ?? "not-a-clerk-key") + } + func configureFromUnknownSource() { + Clerk.configure(publishableKey: ApplicationSecrets.clerkKey) + } + func configureAfterUnicode() { + let note = "🔐" + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"]) + } + func configureWithUnrelatedLaterArgument() { + Clerk.configure(publishableKey: ApplicationSecrets.clerkKey, cache: LocalSecrets.key) + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "Configuration.swift" }, + ]); + + expect(inspection.configureCalls).toEqual([ + { + path: "Configuration.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "process-info-environment", + startupBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "unknown", + startupBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "unknown", + startupBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "process-info-environment", + startupBinding: "unproven", + }, + { + path: "Configuration.swift", + publishableKeyWiring: "unknown", + startupBinding: "unproven", + }, + ]); + const serialized = JSON.stringify(inspection); + expect(serialized).not.toContain("QuickstartLocalSecrets"); + expect(serialized).not.toContain("ProcessInfo"); + expect(serialized).not.toContain("loader-secret"); + expect(serialized).not.toContain("environment-secret"); + expect(serialized).not.toContain("ANALYTICS_KEY"); + }); + + test("proves only an exact LocalSecrets plist runtime binding", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const provenPath = join(root, "LocalSecrets.swift"); + const nameOnlyPath = join(root, "NameOnly.swift"); + const configPath = join(root, "App.swift"); + await Bun.write( + provenPath, + `import Foundation + struct QuickstartLocalSecrets { + let publishableKey: String? + let analyticsKey: String? + + static func load(bundle: Bundle = .main) -> QuickstartLocalSecrets { + guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] + else { return .init(publishableKey: nil, analyticsKey: nil) } + return .init( + publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String, + analyticsKey: values["ANALYTICS_KEY"] as? String + ) + } + } + + struct SameFileFakeLocalSecrets { + let publishableKey: String? + static func load(bundle: Bundle) -> SameFileFakeLocalSecrets { + guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] + else { return .init(publishableKey: nil) } + return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) + } + static func unrelatedDefault(bundle: Bundle = .main) { + _ = bundle + } + } + + struct SideEffectOnlyLocalSecrets { + let publishableKey: String? + static func load() -> SideEffectOnlyLocalSecrets { + let values = ApplicationSecrets.values + _ = unusedCanonicalHelper() + return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) + } + static func unusedCanonicalHelper() -> String? { + guard let url = Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] + else { return nil } + return values["CLERK_PUBLISHABLE_KEY"] as? String + } + } + + enum ApplicationSecrets { + static let values: [String: Any] = [:] + } + + struct ShadowedLocalSecrets { + let publishableKey: String? + static func load(bundle: Bundle = .main) -> ShadowedLocalSecrets { + let values = ApplicationSecrets.values + if let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] { + _ = values + } + return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) + } + }`, + ); + await Bun.write( + nameOnlyPath, + `// Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist") + struct FakeLocalSecrets { + static let key = ApplicationSecrets.clerkKey + static let decoy = "CLERK_PUBLISHABLE_KEY" + }`, + ); + await Bun.write( + configPath, + `import ClerkKit + func configureProven() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") + } + func configureExplicitBundle() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load(bundle: Bundle.module).publishableKey ?? "") + } + func configureWrongReturnedMember() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().analyticsKey ?? "") + } + func configureLargerUnrelatedExpression() { + Clerk.configure(publishableKey: ApplicationSecrets.choose(QuickstartLocalSecrets.load().publishableKey)) + } + func configureAlternateFallback() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? ApplicationSecrets.key) + } + func configureSameFileDecoy() { + Clerk.configure(publishableKey: SameFileFakeLocalSecrets.load().publishableKey ?? "") + } + func configureSideEffectOnlyDecoy() { + Clerk.configure(publishableKey: SideEffectOnlyLocalSecrets.load().publishableKey ?? "") + } + func configureShadowedDecoy() { + Clerk.configure(publishableKey: ShadowedLocalSecrets.load().publishableKey ?? "") + } + func configureDecoy() { + Clerk.configure(publishableKey: FakeLocalSecrets.key) + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: configPath, relativePath: "App.swift" }, + { absolutePath: nameOnlyPath, relativePath: "NameOnly.swift" }, + { absolutePath: provenPath, relativePath: "LocalSecrets.swift" }, + ]); + + expect(inspection.localSecretsRuntimeBindings).toEqual([{ path: "LocalSecrets.swift" }]); + expect(inspection.configureCalls).toEqual([ + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "proven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "unproven", + }, + ]); + expect(JSON.stringify(inspection)).not.toContain("CLERK_PUBLISHABLE_KEY"); + }); + + test("rejects unreachable, conditional, alternate, and mutable delegated resolver returns", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const loaderPath = join(root, "LocalSecrets.swift"); + const configPath = join(root, "App.swift"); + const delegatedLoader = ( + symbol: string, + resolverBody: string, + overrides: { loadBody?: string; normalizerBody?: string; extraMethods?: string } = {}, + ) => ` + struct ${symbol} { + let publishableKey: String? + + static func load( + bundle: Bundle = .main, + processInfo: ProcessInfo = .processInfo + ) -> ${symbol} { + ${ + overrides.loadBody ?? + `let plistValues = localSecretsPlistValues(bundle: bundle) + return .init( + publishableKey: resolveValue( + for: "CLERK_PUBLISHABLE_KEY", + processInfo: processInfo, + plistValues: plistValues + ) + ) + ` + } + } + + static func resolveValue( + for key: String, + processInfo: ProcessInfo, + plistValues: [String: Any] + ) -> String? { + ${resolverBody} + } + + static func normalized(_ value: String?) -> String? { + ${ + overrides.normalizerBody ?? + `guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value` + } + } + + static func localSecretsPlistValues(bundle: Bundle) -> [String: Any] { + guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] + else { return [:] } + return values + } + + ${overrides.extraMethods ?? ""} + } + `; + + await Bun.write( + loaderPath, + `import Foundation + ${delegatedLoader( + "CanonicalDelegatedLocalSecrets", + `guard !key.isEmpty else { + return nil + } + if let environmentValue = normalized(processInfo.environment[key]) { + return environmentValue + } + return normalized(plistValues[key] as? String)`, + )} + ${delegatedLoader( + "UnreachableDecoyLocalSecrets", + `if false { + return normalized(plistValues[key] as? String) + } + return ApplicationSecrets.key`, + )} + ${delegatedLoader( + "ConditionalLookupLocalSecrets", + `if ApplicationSecrets.usePlist { + return normalized(plistValues[key] as? String) + } + return ApplicationSecrets.key`, + )} + ${delegatedLoader( + "AlternatePathLocalSecrets", + `if ApplicationSecrets.useAlternate { + return ApplicationSecrets.key + } + return normalized(plistValues[key] as? String)`, + )} + ${delegatedLoader( + "MultipleDirectReturnsLocalSecrets", + `return normalized(plistValues[key] as? String) + return ApplicationSecrets.key`, + )} + ${delegatedLoader( + "ShadowedDictionaryLocalSecrets", + `let plistValues = ApplicationSecrets.values + return normalized(plistValues[key] as? String)`, + )} + ${delegatedLoader( + "ReassignedDictionaryLocalSecrets", + `plistValues[key] = ApplicationSecrets.key + return normalized(plistValues[key] as? String)`, + )} + ${delegatedLoader( + "ClosureLookupLocalSecrets", + `let lookup = { + return normalized(plistValues[key] as? String) + } + return lookup()`, + )} + ${delegatedLoader( + "ArbitraryWrapperLocalSecrets", + `return alternate(plistValues[key] as? String)`, + { + extraMethods: `static func alternate(_ value: String?) -> String? { + ApplicationSecrets.key + }`, + }, + )} + ${delegatedLoader( + "ReassignedHelperValueLocalSecrets", + `return normalized(plistValues[key] as? String)`, + { + loadBody: `var plistValues = localSecretsPlistValues(bundle: bundle) + plistValues = ApplicationSecrets.values + return .init( + publishableKey: resolveValue( + for: "CLERK_PUBLISHABLE_KEY", + processInfo: processInfo, + plistValues: plistValues + ) + )`, + }, + )} + ${delegatedLoader( + "TrustedSideEffectModuleLocalSecrets", + `return normalized(plistValues[key] as? String)`, + { + loadBody: `_ = localSecretsPlistValues(bundle: bundle) + let plistValues = localSecretsPlistValues(bundle: .module) + return .init( + publishableKey: resolveValue( + for: "CLERK_PUBLISHABLE_KEY", + processInfo: processInfo, + plistValues: plistValues + ) + )`, + }, + )} + ${delegatedLoader( + "AlternateNormalizerLocalSecrets", + `return normalized(plistValues[key] as? String)`, + { + normalizerBody: `guard let value else { return nil } + return ApplicationSecrets.key`, + }, + )}`, + ); + await Bun.write( + configPath, + `import ClerkKit + func configureCanonical() { + Clerk.configure(publishableKey: CanonicalDelegatedLocalSecrets.load().publishableKey ?? "") + } + func configureUnreachableDecoy() { + Clerk.configure(publishableKey: UnreachableDecoyLocalSecrets.load().publishableKey ?? "") + } + func configureConditionalLookup() { + Clerk.configure(publishableKey: ConditionalLookupLocalSecrets.load().publishableKey ?? "") + } + func configureAlternatePath() { + Clerk.configure(publishableKey: AlternatePathLocalSecrets.load().publishableKey ?? "") + } + func configureMultipleReturns() { + Clerk.configure(publishableKey: MultipleDirectReturnsLocalSecrets.load().publishableKey ?? "") + } + func configureShadowedDictionary() { + Clerk.configure(publishableKey: ShadowedDictionaryLocalSecrets.load().publishableKey ?? "") + } + func configureReassignedDictionary() { + Clerk.configure(publishableKey: ReassignedDictionaryLocalSecrets.load().publishableKey ?? "") + } + func configureClosureLookup() { + Clerk.configure(publishableKey: ClosureLookupLocalSecrets.load().publishableKey ?? "") + } + func configureArbitraryWrapper() { + Clerk.configure(publishableKey: ArbitraryWrapperLocalSecrets.load().publishableKey ?? "") + } + func configureReassignedHelperValue() { + Clerk.configure(publishableKey: ReassignedHelperValueLocalSecrets.load().publishableKey ?? "") + } + func configureTrustedSideEffectModule() { + Clerk.configure(publishableKey: TrustedSideEffectModuleLocalSecrets.load().publishableKey ?? "") + } + func configureAlternateNormalizer() { + Clerk.configure(publishableKey: AlternateNormalizerLocalSecrets.load().publishableKey ?? "") + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: configPath, relativePath: "App.swift" }, + { absolutePath: loaderPath, relativePath: "LocalSecrets.swift" }, + ]); + + expect(inspection.configureCalls.map((call) => call.localSecretsRuntimeBinding)).toEqual([ + "proven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + "unproven", + ]); + expect(inspection.localSecretsRuntimeBindings).toEqual([{ path: "LocalSecrets.swift" }]); + }); + + test("does not prove a LocalSecrets loader under conditional compilation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import Foundation + + #if DEBUG + struct ConditionalLocalSecrets { + let publishableKey: String? + static func load() -> ConditionalLocalSecrets { + guard let url = Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = plist as? [String: Any] + else { return .init(publishableKey: nil) } + return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) + } + } + #endif + + @main struct AppMain: App { + init() { + Clerk.configure(publishableKey: ConditionalLocalSecrets.load().publishableKey ?? "") + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.localSecretsRuntimeBindings).toEqual([]); + expect(inspection.configureCalls).toEqual([ + { + path: "App.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "app-init", + localSecretsRuntimeBinding: "unproven", + }, + ]); + }); + + test("proves only a direct call in the @main type's init as startup-bound", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + @main struct AppMain: App { + init() { + Clerk.configure(publishableKey: ApplicationSecrets.direct) + let deferredConfiguration = { + Clerk.configure(publishableKey: ApplicationSecrets.nestedClosure) + } + #if DEBUG + Clerk.configure(publishableKey: ApplicationSecrets.conditional) + #endif + _ = deferredConfiguration + } + + func unusedHelper() { + Clerk.configure(publishableKey: ApplicationSecrets.helper) + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.configureCalls.map((call) => call.startupBinding)).toEqual([ + "app-init", + "unproven", + "unproven", + "unproven", + ]); + }); + + test("only records an open URL handler when its closure forwards to Clerk", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const unrelatedPath = join(root, "Unrelated.swift"); + const clerkPath = join(root, "ClerkCallback.swift"); + await Bun.write( + unrelatedPath, + `import ClerkKit + struct Unrelated: View { + var body: some View { + Text("Hello") + .onOpenURL { url in Analytics.shared.track(url) } + } + func handleElsewhere(_ url: URL) async throws { + try await Clerk.shared.handle(url) + } + }`, + ); + await Bun.write( + clerkPath, + `import ClerkKit + struct Callback: View { + @Environment(Clerk.self) private var clerk + var body: some View { + Text("Hello") + .onOpenURL { url in + Task { try await clerk.handle(url) } + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: unrelatedPath, relativePath: "Unrelated.swift" }, + { absolutePath: clerkPath, relativePath: "ClerkCallback.swift" }, + ]); + + expect(inspection.openURLHandlers).toEqual([{ path: "ClerkCallback.swift" }]); + }); + + test("recognizes native Clerk auth calls without matching unrelated sign-in APIs", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const emailCodePath = join(root, "EmailCode.swift"); + const passwordPath = join(root, "Password.swift"); + const signUpPath = join(root, "SignUp.swift"); + const hostedAuthPath = join(root, "HostedAuth.swift"); + const unrelatedPath = join(root, "UnrelatedFlow.swift"); + await Bun.write( + emailCodePath, + `import ClerkKit + struct EmailCodeFlow { + @Environment(Clerk.self) private var clerk + func run() async throws { + try await clerk.auth.signInWithEmailCode(emailAddress: "person@example.com") + } + }`, + ); + await Bun.write( + passwordPath, + `import ClerkKit + struct PasswordFlow { + func run() async throws { + try await Clerk.shared.auth.signInWithPassword(identifier: "person@example.com", password: "secret") + } + }`, + ); + await Bun.write( + signUpPath, + `import ClerkKit + struct SignUpFlow { + @Environment(Clerk.self) private var clerk + func run() async throws { + _ = try await clerk.auth.signUp(emailAddress: "person@example.com") + } + }`, + ); + await Bun.write( + hostedAuthPath, + `import ClerkKit + struct HostedAuthFlow { + @Environment(Clerk.self) private var clerk + func run() async throws { + _ = try await clerk.auth.startHostedAuth() + } + }`, + ); + await Bun.write( + unrelatedPath, + `struct UnrelatedFlow { + func run() { + _ = AuthView() + clerk.auth.signUp() + analytics.auth.signInWithPassword() + signIn.create() + signUp.prepare() + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: emailCodePath, relativePath: "EmailCode.swift" }, + { absolutePath: passwordPath, relativePath: "Password.swift" }, + { absolutePath: signUpPath, relativePath: "SignUp.swift" }, + { absolutePath: hostedAuthPath, relativePath: "HostedAuth.swift" }, + { absolutePath: unrelatedPath, relativePath: "UnrelatedFlow.swift" }, + ]); + + expect(inspection.authFlowReferences).toEqual([ + { path: "EmailCode.swift" }, + { path: "HostedAuth.swift" }, + { path: "Password.swift" }, + { path: "SignUp.swift" }, + ]); + }); + + test("marks multiple entry points as ambiguous", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const paths = [join(root, "One.swift"), join(root, "Two.swift")]; + await Promise.all(paths.map((path) => Bun.write(path, "@main struct Entry: App {}"))); + + const inspection = await inspectSwiftSources( + paths.map((absolutePath) => ({ + absolutePath, + relativePath: absolutePath.endsWith("One.swift") ? "One.swift" : "Two.swift", + })), + ); + + expect(inspection.status).toBe("ambiguous"); + expect(inspection.entryPoints).toHaveLength(2); + }); + + test("marks source evidence incomplete when a target member is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + + const inspection = await inspectSwiftSources([ + { absolutePath: join(root, "Missing.swift"), relativePath: "Missing.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.sourceFilesScanned).toBe(0); + }); + + test("marks source evidence incomplete when a target member exceeds the scan limit", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Oversized.swift"); + await Bun.write(path, "x".repeat(1_000_001)); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "Oversized.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.sourceFilesScanned).toBe(0); + }); + + test("does not treat preview-only Clerk UI as a shipping authentication flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "ContentView.swift"); + await Bun.write( + path, + `import ClerkKitUI + import SwiftUI + struct ContentView: View { var body: some View { Text("Hello") } } + #Preview { AuthView() } + struct LegacyPreview: PreviewProvider { + static var previews: some View { SignInView() } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "ContentView.swift" }, + ]); + + expect(inspection.authFlowReferences).toEqual([]); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts new file mode 100644 index 000000000..ce727d10f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -0,0 +1,1771 @@ +import { readFile } from "node:fs/promises"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import type { + IOSConfigureCallEvidence, + IOSInlinePublishableKeyInspection, + IOSPublishableKeyWiring, + IOSSourceEvidence, + IOSSwiftInspection, +} from "./types.ts"; + +const MAX_SWIFT_FILE_BYTES = 1_000_000; + +function blankRange(chars: string[], start: number, end: number): void { + for (let i = start; i < end; i++) { + if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " "; + } +} + +/** + * Removes comments and string contents while preserving offsets and newlines. + * This is intentionally a small lexer rather than a regex: Swift supports + * nested block comments, multiline strings, and arbitrary raw-string hashes. + */ +function sanitizeSwift(source: string, blankStrings: boolean): string { + // Swift source offsets below are JavaScript UTF-16 indexes. Split into code + // units so blanking an emoji or other astral character never shifts later + // slices into the original source. + const chars = source.split(""); + let i = 0; + + while (i < chars.length) { + if (chars[i] === "/" && chars[i + 1] === "/") { + const start = i; + i += 2; + while (i < chars.length && chars[i] !== "\n") i++; + blankRange(chars, start, i); + continue; + } + + if (chars[i] === "/" && chars[i + 1] === "*") { + const start = i; + let depth = 1; + i += 2; + while (i < chars.length && depth > 0) { + if (chars[i] === "/" && chars[i + 1] === "*") { + depth++; + i += 2; + } else if (chars[i] === "*" && chars[i + 1] === "/") { + depth--; + i += 2; + } else { + i++; + } + } + blankRange(chars, start, i); + continue; + } + + let hashCount = 0; + while (chars[i + hashCount] === "#") hashCount++; + const quoteIndex = i + hashCount; + if (chars[quoteIndex] !== '"') { + i++; + continue; + } + + const start = i; + const multiline = + chars[quoteIndex] === '"' && chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; + i = quoteIndex + (multiline ? 3 : 1); + + while (i < chars.length) { + const closesQuote = multiline + ? chars[i] === '"' && chars[i + 1] === '"' && chars[i + 2] === '"' + : chars[i] === '"'; + + if (closesQuote) { + const quoteLength = multiline ? 3 : 1; + let closesHashes = true; + for (let h = 0; h < hashCount; h++) { + if (chars[i + quoteLength + h] !== "#") closesHashes = false; + } + if (closesHashes) { + i += quoteLength + hashCount; + break; + } + } + + // Backslash escapes apply directly in normal strings and only when + // followed by the matching number of hashes in raw strings. + if (chars[i] === "\\") { + let escapeHashes = 0; + while (chars[i + 1 + escapeHashes] === "#") escapeHashes++; + if (escapeHashes === hashCount) { + i += 2 + escapeHashes; + continue; + } + } + i++; + } + + if (blankStrings) blankRange(chars, start, i); + } + + return chars.join(""); +} + +export function sanitizeSwiftSource(source: string): string { + return sanitizeSwift(source, true); +} + +function sourceWithoutComments(source: string): string { + return sanitizeSwift(source, false); +} + +function has(source: string, pattern: RegExp): boolean { + pattern.lastIndex = 0; + return pattern.test(source); +} + +const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; +const CLERK_NATIVE_AUTH_FLOW = + /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; + +function topLevelCommaSeparated(source: string): string[] { + const segments: string[] = []; + let start = 0; + let parenthesisDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = 0; index < source.length; index++) { + const character = source[index]; + if (character === "(") parenthesisDepth++; + if (character === ")") parenthesisDepth--; + if (character === "[") bracketDepth++; + if (character === "]") bracketDepth--; + if (character === "{") braceDepth++; + if (character === "}") braceDepth--; + if (character === "," && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + segments.push(source.slice(start, index)); + start = index + 1; + } + } + segments.push(source.slice(start)); + return segments.map((segment) => segment.trim()).filter(Boolean); +} + +interface DirectStaticMethodEvidence { + name: string; + parameters: string; + header: string; + openingBrace: number; + closingBrace: number; +} + +interface BundleParameterEvidence { + externalName: string; + localName: string; + defaultsToMain: boolean; +} + +function directStaticMethods( + source: string, + typeOpeningBrace: number, + typeClosingBrace: number, +): DirectStaticMethodEvidence[] { + const methods: DirectStaticMethodEvidence[] = []; + const methodPattern = /\bstatic\s+func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; + methodPattern.lastIndex = typeOpeningBrace + 1; + let method: RegExpExecArray | null; + + while ((method = methodPattern.exec(source)) !== null && method.index < typeClosingBrace) { + const name = method[1]; + if (!name || braceDepthAt(source, typeOpeningBrace, method.index) !== 1) continue; + const openingParenthesis = source.indexOf("(", method.index); + const closingParenthesis = matchingParenthesis(source, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= typeClosingBrace) continue; + const parameters = source.slice(openingParenthesis + 1, closingParenthesis); + + const methodOpeningBrace = source.indexOf("{", closingParenthesis + 1); + if (methodOpeningBrace === -1 || methodOpeningBrace >= typeClosingBrace) continue; + const methodHeader = source.slice(closingParenthesis + 1, methodOpeningBrace); + if ( + /[;}]/.test(methodHeader) || + /\bfunc\b/.test(methodHeader) || + braceDepthAt(source, typeOpeningBrace, methodOpeningBrace) !== 1 + ) { + continue; + } + const methodClosingBrace = matchingBrace(source, methodOpeningBrace); + if (methodClosingBrace == null || methodClosingBrace > typeClosingBrace) continue; + + methods.push({ + name, + parameters, + header: methodHeader, + openingBrace: methodOpeningBrace, + closingBrace: methodClosingBrace, + }); + methodPattern.lastIndex = methodClosingBrace + 1; + } + + return methods; +} + +function bundleParameters(parameters: string): BundleParameterEvidence[] { + return topLevelCommaSeparated(parameters).flatMap((parameter) => { + const match = + /^(?:(_|[A-Za-z_][A-Za-z0-9_]*)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*Bundle\b([\s\S]*)$/.exec( + parameter, + ); + const localName = match?.[2]; + if (!localName) return []; + return [ + { + externalName: match[1] ?? localName, + localName, + defaultsToMain: /=\s*(?:Bundle\s*)?\.\s*main\b/.test(match[3] ?? ""), + }, + ]; + }); +} + +function directZeroArgumentLoadEvidence( + methods: DirectStaticMethodEvidence[], +): DirectStaticMethodEvidence | undefined { + const candidates = methods.filter((method) => { + if (method.name !== "load" || /\b(?:async|throws|rethrows)\b/.test(method.header)) { + return false; + } + return topLevelCommaSeparated(method.parameters).every((parameter) => parameter.includes("=")); + }); + return candidates.length === 1 ? candidates[0] : undefined; +} + +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function directCallArguments( + source: string, + caller: DirectStaticMethodEvidence, + typeSymbol: string, + calleeName: string, +): string[] { + const bodyStart = caller.openingBrace + 1; + const body = source.slice(bodyStart, caller.closingBrace); + const escapedType = escapeRegularExpression(typeSymbol); + const escapedCallee = escapeRegularExpression(calleeName); + const pattern = new RegExp( + `(?:^|[^A-Za-z0-9_.])(?:(?:Self|${escapedType})\\s*\\.\\s*)?${escapedCallee}\\s*\\(`, + "g", + ); + const argumentsList: string[] = []; + let call: RegExpExecArray | null; + + while ((call = pattern.exec(body)) !== null) { + const localOpeningParenthesis = call.index + call[0].lastIndexOf("("); + const openingParenthesis = bodyStart + localOpeningParenthesis; + if (braceDepthAt(source, caller.openingBrace, openingParenthesis) !== 1) continue; + const closingParenthesis = matchingParenthesis(source, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= caller.closingBrace) continue; + argumentsList.push(source.slice(openingParenthesis + 1, closingParenthesis)); + pattern.lastIndex = closingParenthesis - bodyStart + 1; + } + + return argumentsList; +} + +function reachableStaticMethods( + source: string, + typeSymbol: string, + methods: DirectStaticMethodEvidence[], + root: DirectStaticMethodEvidence, +): DirectStaticMethodEvidence[] | undefined { + const methodsByName = new Map(); + for (const method of methods) { + const existing = methodsByName.get(method.name) ?? []; + existing.push(method); + methodsByName.set(method.name, existing); + } + + const reachable = new Map([[root.openingBrace, root]]); + const pending = [root]; + while (pending.length > 0) { + const caller = pending.shift(); + if (!caller) break; + const callerBody = source.slice(caller.openingBrace + 1, caller.closingBrace); + for (const [name, candidates] of methodsByName) { + const calls = directCallArguments(source, caller, typeSymbol, name); + if (calls.length === 0) continue; + if ( + new RegExp(`\\b(?:let|var|func)\\s+${escapeRegularExpression(name)}\\b`).test(callerBody) + ) { + return undefined; + } + if (candidates.length !== 1) return undefined; + const callee = candidates[0]; + if (callee && !reachable.has(callee.openingBrace)) { + reachable.set(callee.openingBrace, callee); + pending.push(callee); + } + } + } + return [...reachable.values()]; +} + +function localDeclarationPositions( + source: string, + method: DirectStaticMethodEvidence, + name: string, +): number[] { + const bodyStart = method.openingBrace + 1; + const body = source.slice(bodyStart, method.closingBrace); + const pattern = new RegExp(`\\b(?:let|var)\\s+${escapeRegularExpression(name)}\\b`, "g"); + return [...body.matchAll(pattern)].map((match) => bodyStart + match.index); +} + +function hasUniqueDirectImmutableLocalDeclaration( + source: string, + method: DirectStaticMethodEvidence, + name: string, +): boolean { + const bodyStart = method.openingBrace + 1; + const body = source.slice(bodyStart, method.closingBrace); + const declarationPattern = new RegExp(`\\b(let|var)\\s+${escapeRegularExpression(name)}\\b`, "g"); + const declarations = [...body.matchAll(declarationPattern)]; + return ( + declarations.length === 1 && + declarations[0]?.[1] === "let" && + declarations[0].index != null && + braceDepthAt(source, method.openingBrace, bodyStart + declarations[0].index) === 1 + ); +} + +function hasUniqueImmutableInitializedLocal( + source: string, + method: DirectStaticMethodEvidence, + name: string, +): boolean { + if (!hasUniqueDirectImmutableLocalDeclaration(source, method, name)) return false; + const body = source.slice(method.openingBrace + 1, method.closingBrace); + const escapedName = escapeRegularExpression(name); + const declarationsWithInitializers = [ + ...body.matchAll(new RegExp(`\\blet\\s+${escapedName}(?:\\s*:[^=,;{}\\n\\r]+)?\\s*=`, "g")), + ]; + if (declarationsWithInitializers.length !== 1) return false; + + // An immutable declaration may only receive its declaration initializer. + // Property/subscript writes and mutating collection operations make the + // value reaching the key lookup impossible to prove with this scanner. + const writes = [ + ...body.matchAll( + new RegExp( + `\\b${escapedName}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)?\\s*=(?!=)`, + "g", + ), + ), + ]; + if (writes.length !== 1) return false; + return !new RegExp( + `\\b${escapedName}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, + ).test(body); +} + +function hasParameterMutationOrShadowing( + source: string, + method: DirectStaticMethodEvidence, + parameter: string, +): boolean { + if (localDeclarationPositions(source, method, parameter).length > 0) return true; + const body = source.slice(method.openingBrace + 1, method.closingBrace); + const escapedParameter = escapeRegularExpression(parameter); + return ( + new RegExp( + `\\b${escapedParameter}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)?\\s*=(?!=)`, + ).test(body) || + new RegExp( + `\\b${escapedParameter}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, + ).test(body) + ); +} + +interface ExactMainBundleResourceEvidence { + urlVariablesByMethod: Map>; +} + +function exactMainBundleResourceEvidence( + structuralSource: string, + valueSource: string, + typeSymbol: string, + reachableMethods: DirectStaticMethodEvidence[], + loadMethod: DirectStaticMethodEvidence, +): ExactMainBundleResourceEvidence { + const trustedBundleParameters = new Map>([ + [ + loadMethod.openingBrace, + new Set( + bundleParameters(loadMethod.parameters) + .filter((parameter) => parameter.defaultsToMain) + .map((parameter) => parameter.localName), + ), + ], + ]); + const methodsByName = new Map(reachableMethods.map((method) => [method.name, method])); + + let changed = true; + while (changed) { + changed = false; + for (const caller of reachableMethods) { + const trustedCallerNames = new Set( + [...(trustedBundleParameters.get(caller.openingBrace) ?? [])].filter( + (name) => localDeclarationPositions(structuralSource, caller, name).length === 0, + ), + ); + for (const [calleeName, callee] of methodsByName) { + const calleeBundleParameters = bundleParameters(callee.parameters); + if (calleeBundleParameters.length === 0) continue; + for (const callArguments of directCallArguments( + structuralSource, + caller, + typeSymbol, + calleeName, + )) { + const argumentSegments = topLevelCommaSeparated(callArguments); + for (const parameter of calleeBundleParameters) { + if (parameter.externalName === "_") continue; + const escapedLabel = escapeRegularExpression(parameter.externalName); + const trustedArgument = argumentSegments.some((argument) => { + const label = new RegExp(`^${escapedLabel}\\s*:\\s*([A-Za-z_][A-Za-z0-9_]*)$`).exec( + argument, + ); + return label?.[1] != null && trustedCallerNames.has(label[1]); + }); + const literalMainArgument = argumentSegments.some((argument) => + new RegExp(`^${escapedLabel}\\s*:\\s*(?:Bundle\\s*)?\\.\\s*main$`).test(argument), + ); + if (!trustedArgument && !literalMainArgument) continue; + const trustedCalleeNames = + trustedBundleParameters.get(callee.openingBrace) ?? new Set(); + if (!trustedCalleeNames.has(parameter.localName)) { + trustedCalleeNames.add(parameter.localName); + trustedBundleParameters.set(callee.openingBrace, trustedCalleeNames); + changed = true; + } + } + } + } + } + } + + const urlVariablesByMethod = new Map>(); + for (const method of reachableMethods) { + const body = valueSource.slice(method.openingBrace + 1, method.closingBrace); + const urlVariables = new Set(); + for (const match of body.matchAll( + /\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Bundle\s*\.\s*main\s*\.\s*url\s*\(\s*forResource\s*:\s*"LocalSecrets"\s*,\s*withExtension\s*:\s*"plist"/g, + )) { + if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { + urlVariables.add(match[1]); + } + } + for (const variable of trustedBundleParameters.get(method.openingBrace) ?? []) { + if (localDeclarationPositions(structuralSource, method, variable).length > 0) continue; + const pattern = new RegExp( + `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapeRegularExpression(variable)}\\s*\\.\\s*url\\s*\\(\\s*forResource\\s*:\\s*"LocalSecrets"\\s*,\\s*withExtension\\s*:\\s*"plist"`, + "g", + ); + for (const match of body.matchAll(pattern)) { + if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { + urlVariables.add(match[1]); + } + } + } + if (urlVariables.size > 0) urlVariablesByMethod.set(method.openingBrace, urlVariables); + } + return { urlVariablesByMethod }; +} + +interface RedactedExpressionEvidence { + structural: string; + value: string; +} + +interface NamedParameterEvidence { + externalName: string; + localName: string; +} + +function namedParameters(parameters: string): NamedParameterEvidence[] { + return topLevelCommaSeparated(parameters).flatMap((parameter) => { + const match = /^(?:(_|[A-Za-z_][A-Za-z0-9_]*)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(parameter); + const localName = match?.[2]; + if (!localName) return []; + return [{ externalName: match[1] ?? localName, localName }]; + }); +} + +function isTopLevelPosition(source: string, position: number): boolean { + let parenthesisDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = 0; index < position; index++) { + const character = source[index]; + if (character === "(") parenthesisDepth++; + if (character === ")") parenthesisDepth--; + if (character === "[") bracketDepth++; + if (character === "]") bracketDepth--; + if (character === "{") braceDepth++; + if (character === "}") braceDepth--; + } + return parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0; +} + +function topLevelLabeledExpression( + structuralArguments: string, + valueArguments: string, + label: string, +): RedactedExpressionEvidence | undefined { + const labelPattern = new RegExp(`\\b${escapeRegularExpression(label)}\\s*:`, "g"); + const expressions: RedactedExpressionEvidence[] = []; + let match: RegExpExecArray | null; + + while ((match = labelPattern.exec(structuralArguments)) !== null) { + if (!isTopLevelPosition(structuralArguments, match.index)) continue; + const start = match.index + match[0].length; + let end = structuralArguments.length; + let parenthesisDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = start; index < structuralArguments.length; index++) { + const character = structuralArguments[index]; + if (character === "(") parenthesisDepth++; + if (character === ")") parenthesisDepth--; + if (character === "[") bracketDepth++; + if (character === "]") bracketDepth--; + if (character === "{") braceDepth++; + if (character === "}") braceDepth--; + if (character === "," && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + end = index; + break; + } + } + expressions.push({ + structural: structuralArguments.slice(start, end), + value: valueArguments.slice(start, end), + }); + } + + return expressions.length === 1 ? expressions[0] : undefined; +} + +function returnedPublishableKeyExpression( + structuralSource: string, + valueSource: string, + typeSymbol: string, + loadMethod: DirectStaticMethodEvidence, +): RedactedExpressionEvidence | undefined { + const bodyStart = loadMethod.openingBrace + 1; + const structuralBody = structuralSource.slice(bodyStart, loadMethod.closingBrace); + const escapedType = escapeRegularExpression(typeSymbol); + const returnPattern = new RegExp(`\\breturn\\s+(?:\\.\\s*init|${escapedType})\\s*\\(`, "g"); + const expressions: RedactedExpressionEvidence[] = []; + let returnedInitializer: RegExpExecArray | null; + + while ((returnedInitializer = returnPattern.exec(structuralBody)) !== null) { + const returnIndex = bodyStart + returnedInitializer.index; + if (braceDepthAt(structuralSource, loadMethod.openingBrace, returnIndex) !== 1) continue; + const openingParenthesis = + bodyStart + returnedInitializer.index + returnedInitializer[0].lastIndexOf("("); + const closingParenthesis = matchingParenthesis(structuralSource, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= loadMethod.closingBrace) continue; + const structuralArguments = structuralSource.slice(openingParenthesis + 1, closingParenthesis); + const valueArguments = valueSource.slice(openingParenthesis + 1, closingParenthesis); + const expression = topLevelLabeledExpression( + structuralArguments, + valueArguments, + "publishableKey", + ); + if (expression) expressions.push(expression); + returnPattern.lastIndex = closingParenthesis - bodyStart + 1; + } + + return expressions.length === 1 ? expressions[0] : undefined; +} + +function correlatedDecodedDictionaryNames( + structuralSource: string, + valueSource: string, + method: DirectStaticMethodEvidence, + exactURLVariables: Set, +): Set { + const body = valueSource.slice(method.openingBrace + 1, method.closingBrace); + const dataVariables = new Set(); + for (const urlVariable of exactURLVariables) { + const pattern = new RegExp( + `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:try\\s*[?!]?\\s*)?Data\\s*\\(\\s*contentsOf\\s*:\\s*${escapeRegularExpression(urlVariable)}\\b`, + "g", + ); + for (const match of body.matchAll(pattern)) { + if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { + dataVariables.add(match[1]); + } + } + } + + const propertyListVariables = new Set(); + for (const dataVariable of dataVariables) { + const pattern = new RegExp( + `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:try\\s*[?!]?\\s*)?PropertyListSerialization\\s*\\.\\s*propertyList\\s*\\(\\s*from\\s*:\\s*${escapeRegularExpression(dataVariable)}\\b`, + "g", + ); + for (const match of body.matchAll(pattern)) { + if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { + propertyListVariables.add(match[1]); + } + } + } + + const dictionaryVariables = new Set(); + for (const propertyListVariable of propertyListVariables) { + const pattern = new RegExp( + `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapeRegularExpression(propertyListVariable)}\\s+as\\s*\\?\\s*\\[\\s*String\\s*:\\s*Any\\s*\\]`, + "g", + ); + for (const match of body.matchAll(pattern)) { + if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { + dictionaryVariables.add(match[1]); + } + } + } + + let changed = true; + while (changed) { + changed = false; + for (const sourceVariable of dictionaryVariables) { + const aliasPattern = new RegExp( + `(?:^|[;{}\\n\\r])\\s*let\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*:\\s*[^=;{}\\n\\r]+)?\\s*=\\s*${escapeRegularExpression(sourceVariable)}\\b`, + "g", + ); + for (const match of body.matchAll(aliasPattern)) { + const alias = match[1]; + if ( + alias && + !dictionaryVariables.has(alias) && + hasUniqueImmutableInitializedLocal(structuralSource, method, alias) + ) { + dictionaryVariables.add(alias); + changed = true; + } + } + + // Support the direct fixture's definite-initialization form: + // `let values: [String: Any]`, assigned either the decoded immutable + // dictionary or `[:]` in an exact if/else. These are initializations of + // a `let`, not later mutations. + const conditionalAliasPattern = + /\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\[\s*String\s*:\s*Any\s*\](?!\s*=)/g; + for (const aliasDeclaration of body.matchAll(conditionalAliasPattern)) { + const alias = aliasDeclaration[1]; + if ( + !alias || + alias === sourceVariable || + dictionaryVariables.has(alias) || + !hasUniqueDirectImmutableLocalDeclaration(structuralSource, method, alias) + ) { + continue; + } + const escapedAlias = escapeRegularExpression(alias); + const escapedSource = escapeRegularExpression(sourceVariable); + const assignments = [ + ...body.matchAll(new RegExp(`\\b${escapedAlias}\\s*=\\s*([^;{}\\n\\r]+)`, "g")), + ].map((match) => match[1]?.replace(/\s+/g, "")); + if ( + assignments.length !== 2 || + assignments.filter((assignment) => assignment === sourceVariable).length !== 1 || + assignments.filter((assignment) => assignment === "[:]").length !== 1 || + new RegExp( + `\\b${escapedAlias}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)\\s*=(?!=)`, + ).test(body) || + new RegExp( + `\\b${escapedAlias}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, + ).test(body) + ) { + continue; + } + const exactBranches = new RegExp( + `\\{\\s*${escapedAlias}\\s*=\\s*${escapedSource}\\s*\\}\\s*else\\s*\\{\\s*${escapedAlias}\\s*=\\s*\\[\\s*:\\s*\\]\\s*\\}`, + ).test(body); + if (!exactBranches) continue; + dictionaryVariables.add(alias); + changed = true; + } + } + } + return dictionaryVariables; +} + +function directlyReturnedDictionaryName( + structuralSource: string, + method: DirectStaticMethodEvidence, + dictionaryNames: Set, +): string | undefined { + const bodyStart = method.openingBrace + 1; + const body = structuralSource.slice(bodyStart, method.closingBrace); + const returnedNames: string[] = []; + const returnPattern = /\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\b/g; + let returned: RegExpExecArray | null; + while ((returned = returnPattern.exec(body)) !== null) { + const name = returned[1]; + const returnIndex = bodyStart + returned.index; + if ( + name && + dictionaryNames.has(name) && + braceDepthAt(structuralSource, method.openingBrace, returnIndex) === 1 + ) { + returnedNames.push(name); + } + } + return returnedNames.length === 1 ? returnedNames[0] : undefined; +} + +function directExactKeyLookupBase(valueExpression: string): string | undefined { + const subscript = + /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*"CLERK_PUBLISHABLE_KEY"\s*\]\s*(?:as\s*\?\s*String)?\s*$/.exec( + valueExpression, + ); + if (subscript?.[1]) return subscript[1]; + return /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(?:value|object)\s*\(\s*forKey\s*:\s*"CLERK_PUBLISHABLE_KEY"\s*\)\s*(?:as\s*\?\s*String)?\s*$/.exec( + valueExpression, + )?.[1]; +} + +interface ReturnStatementEvidence { + index: number; + depth: number; + structuralExpression: string; + valueExpression: string; +} + +function returnStatements( + structuralSource: string, + valueSource: string, + method: DirectStaticMethodEvidence, +): ReturnStatementEvidence[] { + const bodyStart = method.openingBrace + 1; + const body = structuralSource.slice(bodyStart, method.closingBrace); + const statements: ReturnStatementEvidence[] = []; + const returnPattern = /\breturn\b/g; + let returned: RegExpExecArray | null; + + while ((returned = returnPattern.exec(body)) !== null) { + const returnIndex = bodyStart + returned.index; + let expressionStart = returnIndex + returned[0].length; + while ( + structuralSource[expressionStart] === " " || + structuralSource[expressionStart] === "\t" + ) { + expressionStart++; + } + + let expressionEnd = expressionStart; + let parenthesisDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (; expressionEnd < method.closingBrace; expressionEnd++) { + const character = structuralSource[expressionEnd]; + if ( + (character === "\n" || character === "\r" || character === ";") && + parenthesisDepth === 0 && + bracketDepth === 0 && + braceDepth === 0 + ) { + break; + } + if (character === "}" && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + break; + } + if (character === "(") parenthesisDepth++; + if (character === ")") parenthesisDepth--; + if (character === "[") bracketDepth++; + if (character === "]") bracketDepth--; + if (character === "{") braceDepth++; + if (character === "}") braceDepth--; + } + + statements.push({ + index: returnIndex, + depth: braceDepthAt(structuralSource, method.openingBrace, returnIndex), + structuralExpression: structuralSource.slice(expressionStart, expressionEnd).trim(), + valueExpression: valueSource.slice(expressionStart, expressionEnd).trim(), + }); + returnPattern.lastIndex = Math.max(returnPattern.lastIndex, expressionEnd - bodyStart); + } + + return statements; +} + +type ExactLookupTransform = "direct" | "normalized"; + +function exactResolverLookupTransform( + expression: string, + plistParameter: string, + keyParameter: string, +): ExactLookupTransform | undefined { + const lookup = `${escapeRegularExpression(plistParameter)}\\s*\\[\\s*${escapeRegularExpression(keyParameter)}\\s*\\]\\s*(?:as\\s*\\?\\s*String)?`; + if (new RegExp(`^${lookup}$`).test(expression)) return "direct"; + if (new RegExp(`^normalized\\s*\\(\\s*${lookup}\\s*\\)$`).test(expression)) { + return "normalized"; + } + return undefined; +} + +function isCanonicalNormalizer( + structuralSource: string, + methods: DirectStaticMethodEvidence[], +): boolean { + const candidates = methods.filter((method) => method.name === "normalized"); + if (candidates.length !== 1) return false; + const normalizer = candidates[0]; + if (!normalizer) return false; + const parameters = topLevelCommaSeparated(normalizer.parameters); + if (parameters.length !== 1) return false; + const parameter = /^_\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*\?$/.exec(parameters[0] ?? ""); + const parameterName = parameter?.[1]; + if (!parameterName || !/^\s*->\s*String\s*\?\s*$/.test(normalizer.header)) return false; + + const body = structuralSource.slice(normalizer.openingBrace + 1, normalizer.closingBrace); + const escapedParameter = escapeRegularExpression(parameterName); + const canonicalBody = new RegExp( + `^\\s*guard\\s+let\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapedParameter}\\s*\\?\\s*\\.\\s*trimmingCharacters\\s*\\(\\s*in\\s*:\\s*\\.\\s*whitespacesAndNewlines\\s*\\)\\s*,\\s*!\\s*\\1\\s*\\.\\s*isEmpty\\s+else\\s*\\{\\s*return\\s+nil\\s*;?\\s*\\}\\s*return\\s+\\1\\s*;?\\s*$`, + ); + return canonicalBody.test(body); +} + +function hasResolverParameterMutation( + structuralSource: string, + method: DirectStaticMethodEvidence, + parameter: string, +): boolean { + return hasParameterMutationOrShadowing(structuralSource, method, parameter); +} + +function isExactEnvironmentOverrideReturn( + structuralSource: string, + method: DirectStaticMethodEvidence, + returned: ReturnStatementEvidence, + processInfoParameter: string, + keyParameter: string, + allowNormalized: boolean, +): boolean { + const returnedName = /^([A-Za-z_][A-Za-z0-9_]*)$/.exec(returned.structuralExpression)?.[1]; + if (!returnedName || returned.depth !== 2) return false; + + const bodyStart = method.openingBrace + 1; + const body = structuralSource.slice(bodyStart, method.closingBrace); + const environmentLookup = `${escapeRegularExpression(processInfoParameter)}\\s*\\.\\s*environment\\s*\\[\\s*${escapeRegularExpression(keyParameter)}\\s*\\]`; + const trustedLookup = allowNormalized + ? `(?:${environmentLookup}|normalized\\s*\\(\\s*${environmentLookup}\\s*\\))` + : environmentLookup; + const pattern = new RegExp( + `\\bif\\s+let\\s+${escapeRegularExpression(returnedName)}\\s*=\\s*${trustedLookup}\\s*\\{`, + "g", + ); + let conditional: RegExpExecArray | null; + + while ((conditional = pattern.exec(body)) !== null) { + const openingBrace = bodyStart + conditional.index + conditional[0].lastIndexOf("{"); + if (braceDepthAt(structuralSource, method.openingBrace, openingBrace) !== 1) continue; + const closingBrace = matchingBrace(structuralSource, openingBrace); + if ( + closingBrace == null || + returned.index <= openingBrace || + returned.index >= closingBrace || + braceDepthAt(structuralSource, openingBrace, returned.index) !== 1 + ) { + continue; + } + const afterConditional = structuralSource.slice(closingBrace + 1, method.closingBrace); + if (/^\s*else\b/.test(afterConditional)) return false; + return true; + } + + return false; +} + +function isExactGuardNilReturn( + structuralSource: string, + method: DirectStaticMethodEvidence, + returned: ReturnStatementEvidence, +): boolean { + if (returned.structuralExpression !== "nil" || returned.depth !== 2) return false; + + const bodyStart = method.openingBrace + 1; + const body = structuralSource.slice(bodyStart, method.closingBrace); + const guardPattern = /\bguard\b[^{};]*\belse\s*\{/g; + let guarded: RegExpExecArray | null; + while ((guarded = guardPattern.exec(body)) !== null) { + if (braceDepthAt(structuralSource, method.openingBrace, bodyStart + guarded.index) !== 1) { + continue; + } + const openingBrace = bodyStart + guarded.index + guarded[0].lastIndexOf("{"); + const closingBrace = matchingBrace(structuralSource, openingBrace); + if ( + closingBrace == null || + returned.index <= openingBrace || + returned.index >= closingBrace || + braceDepthAt(structuralSource, openingBrace, returned.index) !== 1 + ) { + continue; + } + return /^\s*return\s+nil\s*;?\s*$/.test(structuralSource.slice(openingBrace + 1, closingBrace)); + } + + return false; +} + +function hasExactDelegatedResolverReturnFlow( + structuralSource: string, + valueSource: string, + resolver: DirectStaticMethodEvidence, + reachableMethods: DirectStaticMethodEvidence[], + resolverParameters: NamedParameterEvidence[], + plistParameter: NamedParameterEvidence, + keyParameter: NamedParameterEvidence, +): boolean { + if ( + hasResolverParameterMutation(structuralSource, resolver, plistParameter.localName) || + hasResolverParameterMutation(structuralSource, resolver, keyParameter.localName) + ) { + return false; + } + + const statements = returnStatements(structuralSource, valueSource, resolver); + const directReturns = statements.filter((returned) => returned.depth === 1); + const directTransform = directReturns[0] + ? exactResolverLookupTransform( + directReturns[0].structuralExpression, + plistParameter.localName, + keyParameter.localName, + ) + : undefined; + if ( + directReturns.length !== 1 || + !directTransform || + (directTransform === "normalized" && !isCanonicalNormalizer(structuralSource, reachableMethods)) + ) { + return false; + } + + const nestedReturns = statements.filter((returned) => returned.depth !== 1); + if (nestedReturns.length === 0) return true; + const processInfoParameter = resolverParameters.find( + (parameter) => parameter.externalName === "processInfo", + ); + if ( + processInfoParameter && + hasResolverParameterMutation(structuralSource, resolver, processInfoParameter.localName) + ) { + return false; + } + let environmentOverrideCount = 0; + for (const returned of nestedReturns) { + if (isExactGuardNilReturn(structuralSource, resolver, returned)) continue; + if ( + processInfoParameter && + isExactEnvironmentOverrideReturn( + structuralSource, + resolver, + returned, + processInfoParameter.localName, + keyParameter.localName, + directTransform === "normalized", + ) + ) { + environmentOverrideCount++; + continue; + } + return false; + } + return environmentOverrideCount <= 1; +} + +function exactDelegatedResolverFlow( + structuralSource: string, + valueSource: string, + typeSymbol: string, + loadMethod: DirectStaticMethodEvidence, + reachableMethods: DirectStaticMethodEvidence[], + resourceEvidence: ExactMainBundleResourceEvidence, + expression: RedactedExpressionEvidence, +): boolean { + const loadParameters = topLevelCommaSeparated(loadMethod.parameters); + if ( + loadParameters.length !== 2 || + !loadParameters.some((parameter) => + /^bundle\s*:\s*Bundle\s*=\s*(?:Bundle\s*)?\.\s*main$/.test(parameter), + ) || + !loadParameters.some((parameter) => + /^processInfo\s*:\s*ProcessInfo\s*=\s*(?:ProcessInfo\s*)?\.\s*processInfo$/.test(parameter), + ) || + hasParameterMutationOrShadowing(structuralSource, loadMethod, "bundle") || + hasParameterMutationOrShadowing(structuralSource, loadMethod, "processInfo") + ) { + return false; + } + + const escapedType = escapeRegularExpression(typeSymbol); + const resolverCall = new RegExp( + `^\\s*(?:(?:Self|${escapedType})\\s*\\.\\s*)?([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`, + ).exec(expression.structural); + const resolverName = resolverCall?.[1]; + if (!resolverCall || !resolverName) return false; + const openingParenthesis = expression.structural.indexOf("(", resolverCall.index); + const closingParenthesis = matchingParenthesis(expression.structural, openingParenthesis); + if ( + closingParenthesis == null || + expression.structural.slice(closingParenthesis + 1).trim() !== "" + ) { + return false; + } + const valueArguments = expression.value.slice(openingParenthesis + 1, closingParenthesis); + const argumentSegments = topLevelCommaSeparated(valueArguments); + if ( + argumentSegments.length !== 3 || + !argumentSegments.some((argument) => /^for\s*:\s*"CLERK_PUBLISHABLE_KEY"\s*$/.test(argument)) + ) { + return false; + } + const plistArgument = argumentSegments + .map((argument) => /^plistValues\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(argument)?.[1]) + .find((value): value is string => value != null); + const processInfoArgument = argumentSegments + .map((argument) => /^processInfo\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(argument)?.[1]) + .find((value): value is string => value != null); + if (!plistArgument || processInfoArgument !== "processInfo") return false; + + const resolverCalls = directCallArguments(structuralSource, loadMethod, typeSymbol, resolverName); + if (resolverCalls.length !== 1) return false; + + const resolverCandidates = reachableMethods.filter((method) => method.name === resolverName); + if (resolverCandidates.length !== 1) return false; + const resolver = resolverCandidates[0]; + if (!resolver) return false; + const resolverParameters = namedParameters(resolver.parameters); + const keyParameter = resolverParameters.find((parameter) => parameter.externalName === "for"); + const processInfoParameter = resolverParameters.find( + (parameter) => parameter.externalName === "processInfo", + ); + const plistParameter = resolverParameters.find( + (parameter) => parameter.externalName === "plistValues", + ); + const resolverParameterSegments = topLevelCommaSeparated(resolver.parameters); + if ( + resolverParameterSegments.length !== 3 || + !resolverParameterSegments.some((parameter) => + /^for\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*String$/.test(parameter), + ) || + !resolverParameterSegments.some((parameter) => + /^processInfo\s*:\s*ProcessInfo$/.test(parameter), + ) || + !resolverParameterSegments.some((parameter) => + /^plistValues\s*:\s*\[\s*String\s*:\s*Any\s*\]$/.test(parameter), + ) || + !keyParameter || + !processInfoParameter || + !plistParameter + ) { + return false; + } + if ( + !hasExactDelegatedResolverReturnFlow( + structuralSource, + valueSource, + resolver, + reachableMethods, + resolverParameters, + plistParameter, + keyParameter, + ) + ) { + return false; + } + + const loadBodyStart = loadMethod.openingBrace + 1; + const structuralLoadBody = structuralSource.slice(loadBodyStart, loadMethod.closingBrace); + const assignmentPattern = new RegExp( + `\\blet\\s+${escapeRegularExpression(plistArgument)}\\s*=\\s*(?:(?:Self|${escapedType})\\s*\\.\\s*)?([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`, + "g", + ); + const resourceAssignments: Array<{ + method: DirectStaticMethodEvidence; + arguments: string; + }> = []; + let assignment: RegExpExecArray | null; + while ((assignment = assignmentPattern.exec(structuralLoadBody)) !== null) { + const assignmentIndex = loadBodyStart + assignment.index; + if (braceDepthAt(structuralSource, loadMethod.openingBrace, assignmentIndex) !== 1) continue; + const resourceName = assignment[1]; + if (!resourceName) continue; + const localOpeningParenthesis = assignment.index + assignment[0].lastIndexOf("("); + const resourceOpeningParenthesis = loadBodyStart + localOpeningParenthesis; + const resourceClosingParenthesis = matchingParenthesis( + structuralSource, + resourceOpeningParenthesis, + ); + if ( + resourceClosingParenthesis == null || + resourceClosingParenthesis >= loadMethod.closingBrace + ) { + continue; + } + const candidates = reachableMethods.filter((method) => method.name === resourceName); + if (candidates.length === 1 && candidates[0]) { + resourceAssignments.push({ + method: candidates[0], + arguments: structuralSource.slice( + resourceOpeningParenthesis + 1, + resourceClosingParenthesis, + ), + }); + } + } + if ( + resourceAssignments.length !== 1 || + !hasUniqueImmutableInitializedLocal(structuralSource, loadMethod, plistArgument) + ) { + return false; + } + const resourceAssignment = resourceAssignments[0]; + if (!resourceAssignment || !/^\s*bundle\s*:\s*bundle\s*$/.test(resourceAssignment.arguments)) { + return false; + } + const resourceMethod = resourceAssignment.method; + const resourceParameters = topLevelCommaSeparated(resourceMethod.parameters); + if ( + resourceParameters.length !== 1 || + !/^bundle\s*:\s*Bundle$/.test(resourceParameters[0] ?? "") || + !/^\s*->\s*\[\s*String\s*:\s*Any\s*\]\s*$/.test(resourceMethod.header) || + hasParameterMutationOrShadowing(structuralSource, resourceMethod, "bundle") + ) { + return false; + } + const resourceCalls = directCallArguments( + structuralSource, + loadMethod, + typeSymbol, + resourceMethod.name, + ); + if (resourceCalls.length !== 1) return false; + + const decodedDictionaries = correlatedDecodedDictionaryNames( + structuralSource, + valueSource, + resourceMethod, + resourceEvidence.urlVariablesByMethod.get(resourceMethod.openingBrace) ?? new Set(), + ); + return ( + directlyReturnedDictionaryName(structuralSource, resourceMethod, decodedDictionaries) != null + ); +} + +function loadReturnsExactPublishableKey( + structuralSource: string, + valueSource: string, + typeSymbol: string, + loadMethod: DirectStaticMethodEvidence, + reachableMethods: DirectStaticMethodEvidence[], + resourceEvidence: ExactMainBundleResourceEvidence, +): boolean { + const expression = returnedPublishableKeyExpression( + structuralSource, + valueSource, + typeSymbol, + loadMethod, + ); + if (!expression) return false; + const directLookupBase = directExactKeyLookupBase(expression.value); + if (directLookupBase) { + const decodedDictionaries = correlatedDecodedDictionaryNames( + structuralSource, + valueSource, + loadMethod, + resourceEvidence.urlVariablesByMethod.get(loadMethod.openingBrace) ?? new Set(), + ); + return decodedDictionaries.has(directLookupBase); + } + return exactDelegatedResolverFlow( + structuralSource, + valueSource, + typeSymbol, + loadMethod, + reachableMethods, + resourceEvidence, + expression, + ); +} + +function provenLocalSecretsRuntimeSymbols(structuralSource: string, valueSource: string): string[] { + const symbols = new Set(); + const declarationPattern = + /\b(?:struct|class|enum|actor)\s+((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\b/g; + let declaration: RegExpExecArray | null; + + while ((declaration = declarationPattern.exec(structuralSource)) !== null) { + const symbol = declaration[1]; + if ( + !symbol || + braceDepthAt(structuralSource, 0, declaration.index) !== 0 || + isInsideConditionalCompilation(structuralSource, declaration.index) + ) { + continue; + } + const openingBrace = structuralSource.indexOf("{", declaration.index + declaration[0].length); + if (openingBrace === -1) continue; + const headerRemainder = structuralSource.slice( + declaration.index + declaration[0].length, + openingBrace, + ); + if (/[;}]/.test(headerRemainder) || /\b(?:struct|class|enum|actor)\b/.test(headerRemainder)) { + continue; + } + const closingBrace = matchingBrace(structuralSource, openingBrace); + if (closingBrace == null) continue; + + const structuralBody = structuralSource.slice(openingBrace + 1, closingBrace); + // Any conditional member makes it ambiguous whether the loader and its + // resource/key path ship in the selected configuration. + if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(structuralBody)) continue; + + const methods = directStaticMethods(structuralSource, openingBrace, closingBrace); + const loadEvidence = directZeroArgumentLoadEvidence(methods); + if (!loadEvidence) continue; + const reachableMethods = reachableStaticMethods( + structuralSource, + symbol, + methods, + loadEvidence, + ); + if (!reachableMethods) continue; + + const resourceEvidence = exactMainBundleResourceEvidence( + structuralSource, + valueSource, + symbol, + reachableMethods, + loadEvidence, + ); + if ( + resourceEvidence.urlVariablesByMethod.size > 0 && + loadReturnsExactPublishableKey( + structuralSource, + valueSource, + symbol, + loadEvidence, + reachableMethods, + resourceEvidence, + ) + ) { + symbols.add(symbol); + } + } + + return [...symbols].sort(); +} + +function matchingBrace(source: string, openingBrace: number): number | undefined { + let depth = 0; + for (let index = openingBrace; index < source.length; index++) { + if (source[index] === "{") depth++; + if (source[index] !== "}") continue; + depth--; + if (depth === 0) return index; + } + return undefined; +} + +function matchingParenthesis(source: string, openingParenthesis: number): number | undefined { + let depth = 0; + for (let index = openingParenthesis; index < source.length; index++) { + if (source[index] === "(") depth++; + if (source[index] !== ")") continue; + depth--; + if (depth === 0) return index; + } + return undefined; +} + +interface SourceBodyRange { + openingBrace: number; + closingBrace: number; +} + +const TYPE_DECLARATION_MODIFIERS = new Set([ + "final", + "fileprivate", + "indirect", + "internal", + "nonisolated", + "open", + "package", + "private", + "public", +]); + +function skipWhitespace(source: string, start: number): number { + let cursor = start; + while (/\s/.test(source[cursor] ?? "")) cursor++; + return cursor; +} + +function mainTypeBodies(source: string): SourceBodyRange[] { + const bodies: SourceBodyRange[] = []; + const mainPattern = /@main\b/g; + let mainMatch: RegExpExecArray | null; + + while ((mainMatch = mainPattern.exec(source)) !== null) { + let cursor = mainMatch.index + mainMatch[0].length; + + // Other declaration attributes and access modifiers may appear between + // @main and the type declaration. Anything else makes the proof fail + // closed rather than guessing which declaration owns the attribute. + while (true) { + cursor = skipWhitespace(source, cursor); + const attribute = /^@[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*/.exec( + source.slice(cursor), + ); + if (attribute) { + cursor += attribute[0].length; + const attributeArguments = skipWhitespace(source, cursor); + if (source[attributeArguments] === "(") { + const closingParenthesis = matchingParenthesis(source, attributeArguments); + if (closingParenthesis == null) break; + cursor = closingParenthesis + 1; + } + continue; + } + + const modifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(cursor))?.[0]; + if (modifier && TYPE_DECLARATION_MODIFIERS.has(modifier)) { + cursor += modifier.length; + if (modifier === "nonisolated") { + const modifierArguments = skipWhitespace(source, cursor); + if (source[modifierArguments] === "(") { + const closingParenthesis = matchingParenthesis(source, modifierArguments); + if (closingParenthesis == null) break; + cursor = closingParenthesis + 1; + } + } + continue; + } + break; + } + + cursor = skipWhitespace(source, cursor); + const declaration = /^(?:struct|class|enum|actor)\s+[A-Za-z_][A-Za-z0-9_]*/.exec( + source.slice(cursor), + ); + if (!declaration) continue; + + const headerEnd = cursor + declaration[0].length; + const openingBrace = source.indexOf("{", headerEnd); + if (openingBrace === -1) continue; + const headerRemainder = source.slice(headerEnd, openingBrace); + if (/[;}]/.test(headerRemainder) || /@main\b/.test(headerRemainder)) continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) continue; + bodies.push({ openingBrace, closingBrace }); + mainPattern.lastIndex = closingBrace + 1; + } + + return bodies; +} + +function braceDepthAt(source: string, openingBrace: number, position: number): number { + let depth = 0; + for (let index = openingBrace; index < position; index++) { + if (source[index] === "{") depth++; + if (source[index] === "}") depth--; + } + return depth; +} + +function isInsideConditionalCompilation(source: string, position: number): boolean { + const directive = /^[\t ]*#(if|elseif|else|endif)\b/gm; + let depth = 0; + let match: RegExpExecArray | null; + while ((match = directive.exec(source)) !== null && match.index < position) { + if (match[1] === "if") depth++; + if (match[1] === "endif") depth = Math.max(0, depth - 1); + } + return depth > 0; +} + +function mainInitializerBodies(source: string): SourceBodyRange[] { + const mainTypes = mainTypeBodies(source); + // Multiple @main declarations in one file are ambiguous even though the + // file-level entry-point evidence has only one path. + if (mainTypes.length !== 1) return []; + + const typeBody = mainTypes[0]; + if (!typeBody) return []; + const initializers: SourceBodyRange[] = []; + const initializerPattern = /\binit\s*([?!])?\s*\(/g; + initializerPattern.lastIndex = typeBody.openingBrace + 1; + let match: RegExpExecArray | null; + + while ( + (match = initializerPattern.exec(source)) !== null && + match.index < typeBody.closingBrace + ) { + if ( + match[1] != null || + braceDepthAt(source, typeBody.openingBrace, match.index) !== 1 || + isInsideConditionalCompilation(source, match.index) + ) { + continue; + } + + const openingParenthesis = source.indexOf("(", match.index); + const closingParenthesis = matchingParenthesis(source, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= typeBody.closingBrace) continue; + if (source.slice(openingParenthesis + 1, closingParenthesis).trim() !== "") continue; + + const openingBrace = skipWhitespace(source, closingParenthesis + 1); + if (source[openingBrace] !== "{") continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null || closingBrace > typeBody.closingBrace) continue; + initializers.push({ openingBrace, closingBrace }); + initializerPattern.lastIndex = closingBrace + 1; + } + + return initializers; +} + +function isDirectStatementInMainInitializer( + source: string, + callIndex: number, + initializerBodies: SourceBodyRange[], +): boolean { + const initializer = initializerBodies.find( + (body) => callIndex > body.openingBrace && callIndex < body.closingBrace, + ); + if (!initializer) return false; + if (braceDepthAt(source, initializer.openingBrace, callIndex) !== 1) return false; + if (isInsideConditionalCompilation(source, callIndex)) return false; + + let cursor = callIndex - 1; + while (source[cursor] === " " || source[cursor] === "\t") cursor--; + return ( + cursor === initializer.openingBrace || + source[cursor] === "\n" || + source[cursor] === "\r" || + source[cursor] === ";" || + source[cursor] === "}" + ); +} + +function publishableKeyWiring( + sanitizedCallBody: string, + originalCallBody: string, +): { + wiring: IOSPublishableKeyWiring; + inlinePublishableKey?: IOSInlinePublishableKeyInspection; + localSecretsSymbol?: string; + localSecretsUsesCanonicalLoad?: boolean; +} { + const label = /\bpublishableKey\s*:/.exec(sanitizedCallBody); + if (!label) return { wiring: "unknown" }; + const expressionStart = label.index + label[0].length; + let expressionEnd = sanitizedCallBody.length; + let parenthesisDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + for (let index = expressionStart; index < sanitizedCallBody.length; index++) { + const character = sanitizedCallBody[index]; + if (character === "(") parenthesisDepth++; + if (character === ")") parenthesisDepth--; + if (character === "[") bracketDepth++; + if (character === "]") bracketDepth--; + if (character === "{") braceDepth++; + if (character === "}") braceDepth--; + if (character === "," && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + expressionEnd = index; + break; + } + } + const expression = sanitizedCallBody.slice(expressionStart, expressionEnd); + const originalExpression = originalCallBody.slice(expressionStart, expressionEnd); + + // A direct ordinary string literal is the documented native iOS setup. Only + // retain decoded, non-secret metadata; the literal itself must never enter + // inspection, JSON, diagnostics, or telemetry. + const inlineLiteral = /^\s*"([^"\\]*)"\s*$/.exec(originalExpression); + if (inlineLiteral?.[1] != null && expression.trim() === "") { + try { + const decoded = decodePublishableKey(inlineLiteral[1]); + return { + wiring: "inline-literal", + inlinePublishableKey: { + state: "valid", + frontendApiHost: decoded.fapiHost, + instanceType: decoded.instanceType, + }, + }; + } catch { + return { + wiring: "inline-literal", + inlinePublishableKey: { state: "invalid" }, + }; + } + } + + const localSecrets = + /\b((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\b\s*\.\s*(load\s*\(|(?:key|publishableKey)\b)/.exec( + expression, + ); + if (localSecrets?.[1]) { + const canonicalLoad = + /^\s*((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\s*\.\s*load\s*\(\s*\)\s*\.\s*publishableKey\b([\s\S]*)$/.exec( + expression, + ); + const remainder = canonicalLoad?.[2] ?? ""; + const originalRemainder = originalExpression.slice(expression.length - remainder.length); + const canonicalRemainder = + remainder.trim() === "" || /^\s*\?\?\s*""\s*$/.test(originalRemainder); + return { + wiring: "local-secrets-loader", + localSecretsSymbol: localSecrets[1], + localSecretsUsesCanonicalLoad: canonicalLoad?.[1] === localSecrets[1] && canonicalRemainder, + }; + } + if ( + has(expression, /\bProcessInfo\s*\.\s*processInfo\s*\.\s*environment\b/) && + has( + originalExpression, + /\bProcessInfo\s*\.\s*processInfo\s*\.\s*environment\s*\[\s*"CLERK_PUBLISHABLE_KEY"\s*\]/, + ) + ) { + return { wiring: "process-info-environment" }; + } + return { wiring: "unknown" }; +} + +interface PendingConfigureCall extends IOSConfigureCallEvidence { + localSecretsSymbol?: string; + localSecretsUsesCanonicalLoad?: boolean; +} + +function configureCallEvidence( + sanitizedSource: string, + originalSource: string, + evidence: IOSSourceEvidence, +): PendingConfigureCall[] { + const calls: PendingConfigureCall[] = []; + const initializerBodies = mainInitializerBodies(sanitizedSource); + const pattern = /\bClerk\s*\.\s*configure\s*\(/g; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(sanitizedSource)) !== null) { + const openingParenthesis = sanitizedSource.indexOf("(", match.index); + const closingParenthesis = matchingParenthesis(sanitizedSource, openingParenthesis); + if (closingParenthesis == null) { + calls.push({ + ...evidence, + publishableKeyWiring: "unknown", + startupBinding: isDirectStatementInMainInitializer( + sanitizedSource, + match.index, + initializerBodies, + ) + ? "app-init" + : "unproven", + }); + break; + } + const classification = publishableKeyWiring( + sanitizedSource.slice(openingParenthesis + 1, closingParenthesis), + originalSource.slice(openingParenthesis + 1, closingParenthesis), + ); + calls.push({ + ...evidence, + publishableKeyWiring: classification.wiring, + inlinePublishableKey: classification.inlinePublishableKey, + startupBinding: isDirectStatementInMainInitializer( + sanitizedSource, + match.index, + initializerBodies, + ) + ? "app-init" + : "unproven", + localSecretsSymbol: classification.localSecretsSymbol, + localSecretsUsesCanonicalLoad: classification.localSecretsUsesCanonicalLoad, + }); + pattern.lastIndex = closingParenthesis + 1; + } + + return calls; +} + +function withoutPreviewOnlyRegions(source: string): string { + const chars = source.split(""); + const patterns = [/#Preview\b/g, /\bstruct\s+\w+[^{}]*:\s*[^{}]*\bPreviewProvider\b/g]; + for (const pattern of patterns) { + for (const match of source.matchAll(pattern)) { + const openingBrace = source.indexOf("{", match.index + match[0].length); + if (openingBrace === -1) continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) continue; + blankRange(chars, match.index, closingBrace + 1); + } + } + return chars.join(""); +} + +function hasClerkOpenURLHandler(source: string): boolean { + const pattern = /\.\s*onOpenURL\b/g; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(source)) !== null) { + let cursor = match.index + match[0].length; + while (/\s/.test(source[cursor] ?? "")) cursor++; + + let openingBrace: number | undefined; + if (source[cursor] === "{") { + openingBrace = cursor; + } else if (source[cursor] === "(") { + const closingParenthesis = source.indexOf(")", cursor + 1); + const candidateBrace = source.indexOf("{", cursor + 1); + if ( + candidateBrace !== -1 && + (closingParenthesis === -1 || candidateBrace < closingParenthesis) + ) { + openingBrace = candidateBrace; + } + } + + if (openingBrace == null) continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) continue; + if (has(source.slice(openingBrace + 1, closingBrace), CLERK_URL_HANDLER)) return true; + pattern.lastIndex = closingBrace + 1; + } + + return false; +} + +export async function inspectSwiftSources( + sourceFiles: Array<{ absolutePath: string; relativePath: string }>, + options: { membershipComplete?: boolean } = {}, +): Promise { + const entryPoints: IOSSourceEvidence[] = []; + const importsClerkKit: IOSSourceEvidence[] = []; + const importsClerkKitUI: IOSSourceEvidence[] = []; + const pendingConfigureCalls: PendingConfigureCall[] = []; + const localSecretsRuntimeBindings: IOSSourceEvidence[] = []; + const localSecretsRuntimeSymbols = new Set(); + const environmentInjections: IOSSourceEvidence[] = []; + const environmentConsumers: IOSSourceEvidence[] = []; + const authFlowReferences: IOSSourceEvidence[] = []; + const openURLHandlers: IOSSourceEvidence[] = []; + let sourceFilesScanned = 0; + let evidenceComplete = options.membershipComplete ?? true; + + for (const file of sourceFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) { + const diskFile = Bun.file(file.absolutePath); + if (!(await diskFile.exists()) || diskFile.size > MAX_SWIFT_FILE_BYTES) { + evidenceComplete = false; + continue; + } + + let source: string; + try { + source = await readFile(file.absolutePath, "utf8"); + } catch { + evidenceComplete = false; + continue; + } + + sourceFilesScanned++; + const sanitized = withoutPreviewOnlyRegions(sanitizeSwiftSource(source)); + const uncommented = withoutPreviewOnlyRegions(sourceWithoutComments(source)); + const evidence = { path: file.relativePath }; + const importsKit = has( + sanitized, + /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKit\b/, + ); + const importsUI = has( + sanitized, + /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKitUI\b/, + ); + const importsClerkModule = importsKit || importsUI; + + if (has(sanitized, /@main\b/)) entryPoints.push(evidence); + if (importsKit) importsClerkKit.push(evidence); + if (importsUI) importsClerkKitUI.push(evidence); + const runtimeSymbols = provenLocalSecretsRuntimeSymbols(sanitized, uncommented); + for (const symbol of runtimeSymbols) { + localSecretsRuntimeSymbols.add(symbol); + localSecretsRuntimeBindings.push(evidence); + } + if (importsClerkModule) { + pendingConfigureCalls.push(...configureCallEvidence(sanitized, source, evidence)); + } + if ( + importsClerkModule && + has(sanitized, /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/) + ) { + environmentInjections.push(evidence); + } + if (importsClerkModule && has(sanitized, /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/)) { + environmentConsumers.push(evidence); + } + if ( + (importsUI && has(sanitized, /\bAuthView\s*\(/)) || + (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW)) + ) { + authFlowReferences.push(evidence); + } + if (importsClerkModule && hasClerkOpenURLHandler(sanitized)) { + openURLHandlers.push(evidence); + } + } + + const anyClerkEvidence = + importsClerkKit.length + + importsClerkKitUI.length + + pendingConfigureCalls.length + + localSecretsRuntimeBindings.length + + environmentInjections.length + + environmentConsumers.length + + authFlowReferences.length > + 0; + const status = + entryPoints.length > 1 + ? "ambiguous" + : pendingConfigureCalls.length > 0 && environmentInjections.length > 0 + ? "complete" + : anyClerkEvidence + ? "partial" + : "absent"; + + const configureCalls: IOSConfigureCallEvidence[] = pendingConfigureCalls.map( + ({ localSecretsSymbol, localSecretsUsesCanonicalLoad, ...call }) => ({ + ...call, + ...(call.publishableKeyWiring === "local-secrets-loader" && { + localSecretsRuntimeBinding: + localSecretsUsesCanonicalLoad && + localSecretsSymbol && + localSecretsRuntimeSymbols.has(localSecretsSymbol) + ? ("proven" as const) + : ("unproven" as const), + }), + }), + ); + + return { + sourceFilesScanned, + evidenceComplete, + entryPoints, + importsClerkKit, + importsClerkKitUI, + configureCalls, + localSecretsRuntimeBindings, + environmentInjections, + environmentConsumers, + authFlowReferences, + openURLHandlers, + status, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/test-helpers.ts b/packages/cli-core/src/commands/init/ios/test-helpers.ts new file mode 100644 index 000000000..322c03eaa --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/test-helpers.ts @@ -0,0 +1,342 @@ +import { lstat, mkdir, readdir, readFile, readlink, rm, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import type { PbxObjects } from "./pbx.ts"; + +const IDS = { + project: "AAAAAAAAAAAAAAAAAAAAAAAA", + mainGroup: "BBBBBBBBBBBBBBBBBBBBBBBB", + appGroup: "CCCCCCCCCCCCCCCCCCCCCCCC", + appFile: "DDDDDDDDDDDDDDDDDDDDDDDD", + entitlementsFile: "EEEEEEEEEEEEEEEEEEEEEEEE", + appTarget: "111111111111111111111111", + appProduct: "121212121212121212121212", + projectConfigList: "131313131313131313131313", + projectDebug: "141414141414141414141414", + projectRelease: "151515151515151515151515", + targetConfigList: "161616161616161616161616", + targetDebug: "171717171717171717171717", + targetRelease: "181818181818181818181818", + sourcesPhase: "191919191919191919191919", + sourceBuildFile: "202020202020202020202020", + frameworksPhase: "212121212121212121212121", + clerkPackage: "222222222222222222222222", + clerkKit: "232323232323232323232323", + clerkKitUI: "242424242424242424242424", + clerkKitBuildFile: "252525252525252525252525", + clerkKitUIBuildFile: "262626262626262626262626", + secondTarget: "313131313131313131313131", + secondProduct: "323232323232323232323232", + secondConfigList: "333333333333333333333333", + secondDebug: "343434343434343434343434", + secondRelease: "353535353535353535353535", + targetXCConfig: "363636363636363636363636", + localSecretsFile: "373737373737373737373737", + resourcesPhase: "383838383838383838383838", + localSecretsBuildFile: "393939393939393939393939", + secondGroup: "404040404040404040404040", + secondAppFile: "414141414141414141414141", + secondSourcesPhase: "424242424242424242424242", + secondSourceBuildFile: "434343434343434343434343", + secondFrameworksPhase: "444444444444444444444444", +} as const; + +export interface IOSFixtureOptions { + complete?: boolean; + secondTarget?: boolean | "watchos"; + conflictingBundle?: boolean; + includeKey?: boolean; + releaseEntitlements?: boolean; + workspace?: boolean; + generated?: "xcodegen" | "tuist"; + xcconfig?: boolean; + localSecrets?: boolean; + /** Include a fully linked clerk-ios package graph. Defaults to both products. */ + clerkSDK?: boolean | "core-only"; +} + +function secondTargetObjects(platform: "ios" | "watchos"): string { + const isWatchOS = platform === "watchos"; + const directoryName = isWatchOS ? "WatchApp" : "AdminApp"; + const sourceName = isWatchOS ? "WatchAppApp.swift" : "AdminAppApp.swift"; + const targetName = isWatchOS ? "MyApp Watch App" : "AdminApp"; + const platformSettings = isWatchOS + ? 'PRODUCT_BUNDLE_IDENTIFIER = com.example.WatchApp; DEVELOPMENT_TEAM = ABCDE12345; SDKROOT = watchos; SUPPORTED_PLATFORMS = "watchos watchsimulator"; WATCHOS_DEPLOYMENT_TARGET = 10.0;' + : 'PRODUCT_BUNDLE_IDENTIFIER = com.example.AdminApp; DEVELOPMENT_TEAM = ABCDE12345; IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";'; + return ` + ${IDS.secondGroup} = { isa = PBXGroup; children = ( ${IDS.secondAppFile}, ); path = ${directoryName}; sourceTree = ""; }; + ${IDS.secondAppFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ${sourceName}; sourceTree = ""; }; + ${IDS.secondTarget} = { + isa = PBXNativeTarget; + buildConfigurationList = ${IDS.secondConfigList}; + buildPhases = ( ${IDS.secondSourcesPhase}, ${IDS.secondFrameworksPhase}, ); + buildRules = ( ); + dependencies = ( ); + name = "${targetName}"; + productName = "${targetName}"; + productReference = ${IDS.secondProduct}; + productType = "com.apple.product-type.application"; + packageProductDependencies = ( ); + }; + ${IDS.secondProduct} = { isa = PBXFileReference; explicitFileType = wrapper.application; path = ${directoryName}.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ${IDS.secondSourcesPhase} = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ${IDS.secondSourceBuildFile}, ); runOnlyForDeploymentPostprocessing = 0; }; + ${IDS.secondSourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IDS.secondAppFile}; }; + ${IDS.secondFrameworksPhase} = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; + ${IDS.secondConfigList} = { isa = XCConfigurationList; buildConfigurations = ( ${IDS.secondDebug}, ${IDS.secondRelease}, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + ${IDS.secondDebug} = { isa = XCBuildConfiguration; buildSettings = { ${platformSettings} }; name = Debug; }; + ${IDS.secondRelease} = { isa = XCBuildConfiguration; buildSettings = { ${platformSettings} }; name = Release; }; + `; +} + +function pbxproj(options: IOSFixtureOptions): string { + const includeClerkSDK = options.clerkSDK !== false; + const includeClerkKitUI = includeClerkSDK && options.clerkSDK !== "core-only"; + const releaseBundle = options.conflictingBundle + ? "com.example.MyApp.release" + : "com.example.MyApp"; + const releaseEntitlements = + options.releaseEntitlements === false + ? "" + : "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;"; + const debugIdentitySettings = options.xcconfig + ? "" + : "DEVELOPMENT_TEAM = ABCDE12345; PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;"; + const releaseIdentitySettings = options.xcconfig + ? "" + : `DEVELOPMENT_TEAM = ABCDE12345; PRODUCT_BUNDLE_IDENTIFIER = ${releaseBundle};`; + const baseConfigurationReference = options.xcconfig + ? `baseConfigurationReference = ${IDS.targetXCConfig};` + : ""; + const targetIds = options.secondTarget + ? `${IDS.appTarget}, ${IDS.secondTarget},` + : `${IDS.appTarget},`; + return `// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { }; + objectVersion = 56; + objects = { + ${IDS.project} = { + isa = PBXProject; + attributes = { LastUpgradeCheck = 1600; }; + buildConfigurationList = ${IDS.projectConfigList}; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + knownRegions = ( en, Base, ); + mainGroup = ${IDS.mainGroup}; + packageReferences = ( ${includeClerkSDK ? `${IDS.clerkPackage},` : ""} ); + projectDirPath = ""; + projectRoot = ""; + targets = ( ${targetIds} ); + }; + ${IDS.mainGroup} = { isa = PBXGroup; children = ( ${IDS.appGroup}, ${options.secondTarget ? `${IDS.secondGroup},` : ""} ${IDS.entitlementsFile}, ${options.xcconfig ? `${IDS.targetXCConfig},` : ""} ); sourceTree = ""; }; + ${IDS.appGroup} = { isa = PBXGroup; children = ( ${IDS.appFile}, ${options.localSecrets ? `${IDS.localSecretsFile},` : ""} ); path = MyApp; sourceTree = ""; }; + ${IDS.appFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; }; + ${options.localSecrets ? `${IDS.localSecretsFile} = { isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = LocalSecrets.plist; sourceTree = ""; };` : ""} + ${IDS.entitlementsFile} = { isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MyApp/MyApp.entitlements; sourceTree = ""; }; + ${options.xcconfig ? `${IDS.targetXCConfig} = { isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Target.xcconfig; sourceTree = ""; };` : ""} + ${IDS.appTarget} = { + isa = PBXNativeTarget; + buildConfigurationList = ${IDS.targetConfigList}; + buildPhases = ( ${IDS.sourcesPhase}, ${IDS.frameworksPhase}, ${options.localSecrets ? `${IDS.resourcesPhase},` : ""} ); + buildRules = ( ); + dependencies = ( ); + name = MyApp; + productName = MyApp; + productReference = ${IDS.appProduct}; + productType = "com.apple.product-type.application"; + packageProductDependencies = ( ${includeClerkSDK ? `${IDS.clerkKit},` : ""} ${includeClerkKitUI ? `${IDS.clerkKitUI},` : ""} ); + }; + ${IDS.appProduct} = { isa = PBXFileReference; explicitFileType = wrapper.application; path = MyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ${IDS.sourcesPhase} = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ${IDS.sourceBuildFile}, ); runOnlyForDeploymentPostprocessing = 0; }; + ${IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IDS.appFile}; }; + ${options.localSecrets ? `${IDS.resourcesPhase} = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ${IDS.localSecretsBuildFile}, ); runOnlyForDeploymentPostprocessing = 0; }; ${IDS.localSecretsBuildFile} = { isa = PBXBuildFile; fileRef = ${IDS.localSecretsFile}; };` : ""} + ${IDS.frameworksPhase} = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ${includeClerkSDK ? `${IDS.clerkKitBuildFile},` : ""} ${includeClerkKitUI ? `${IDS.clerkKitUIBuildFile},` : ""} ); runOnlyForDeploymentPostprocessing = 0; }; + ${includeClerkSDK ? `${IDS.clerkKitBuildFile} = { isa = PBXBuildFile; productRef = ${IDS.clerkKit}; };` : ""} + ${includeClerkKitUI ? `${IDS.clerkKitUIBuildFile} = { isa = PBXBuildFile; productRef = ${IDS.clerkKitUI}; };` : ""} + ${includeClerkSDK ? `${IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/clerk/clerk-ios.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };` : ""} + ${includeClerkSDK ? `${IDS.clerkKit} = { isa = XCSwiftPackageProductDependency; package = ${IDS.clerkPackage}; productName = ClerkKit; };` : ""} + ${includeClerkKitUI ? `${IDS.clerkKitUI} = { isa = XCSwiftPackageProductDependency; package = ${IDS.clerkPackage}; productName = ClerkKitUI; };` : ""} + ${IDS.projectConfigList} = { isa = XCConfigurationList; buildConfigurations = ( ${IDS.projectDebug}, ${IDS.projectRelease}, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + ${IDS.projectDebug} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Debug; }; + ${IDS.projectRelease} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Release; }; + ${IDS.targetConfigList} = { isa = XCConfigurationList; buildConfigurations = ( ${IDS.targetDebug}, ${IDS.targetRelease}, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + ${IDS.targetDebug} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; ${debugIdentitySettings} IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Debug; }; + ${IDS.targetRelease} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { ${releaseEntitlements} ${releaseIdentitySettings} IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Release; }; + ${options.secondTarget ? secondTargetObjects(options.secondTarget === "watchos" ? "watchos" : "ios") : ""} + }; + rootObject = ${IDS.project}; +} +`; +} + +function swiftSource(complete: boolean): string { + if (!complete) { + return `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Hello") } } +} +`; + } + return `import ClerkKit +import ClerkKitUI +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") } + var body: some Scene { + WindowGroup { + AuthView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } +} + +struct QuickstartLocalSecrets { + let publishableKey: String? + + static func load(bundle: Bundle = .main) -> QuickstartLocalSecrets { + let values: [String: Any] + if let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let dictionary = plist as? [String: Any] { + values = dictionary + } else { + values = [:] + } + return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) + } +} +`; +} + +function secondSwiftSource(platform: "ios" | "watchos"): string { + const appName = platform === "watchos" ? "WatchAppApp" : "AdminAppApp"; + return `import SwiftUI + +@main +struct ${appName}: App { + var body: some Scene { WindowGroup { Text("Hello") } } +} +`; +} + +const ENTITLEMENTS = ` + + +application-identifierLEGACY1234.com.example.MyApp +com.apple.developer.team-identifierABCDE12345 +com.apple.developer.associated-domainswebcredentials:clerk.example.test + +`; + +export async function createIOSFixture( + root: string, + options: IOSFixtureOptions = {}, +): Promise { + const project = join(root, "MyApp.xcodeproj"); + await mkdir(join(project), { recursive: true }); + await mkdir(join(root, "MyApp"), { recursive: true }); + await Bun.write(join(project, "project.pbxproj"), pbxproj(options)); + await Bun.write(join(root, "MyApp", "MyAppApp.swift"), swiftSource(options.complete === true)); + await Bun.write(join(root, "MyApp", "MyApp.entitlements"), ENTITLEMENTS); + if (options.secondTarget) { + const platform = options.secondTarget === "watchos" ? "watchos" : "ios"; + const directoryName = platform === "watchos" ? "WatchApp" : "AdminApp"; + const sourceName = platform === "watchos" ? "WatchAppApp.swift" : "AdminAppApp.swift"; + await mkdir(join(root, directoryName), { recursive: true }); + await Bun.write(join(root, directoryName, sourceName), secondSwiftSource(platform)); + } + if (options.localSecrets) { + const encodedHost = Buffer.from("native.clerk.example$").toString("base64"); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEYpk_live_${encodedHost}`, + ); + } + + if (options.xcconfig) { + await mkdir(join(root, "Config"), { recursive: true }); + await Bun.write( + join(root, "Config", "Base.xcconfig"), + "BUNDLE_BASE = com.example\nDEVELOPMENT_TEAM = ABCDE12345\n", + ); + await Bun.write( + join(root, "Config", "Target.xcconfig"), + '#include "Base.xcconfig"\nPRODUCT_BUNDLE_IDENTIFIER = $(BUNDLE_BASE).MyApp\n', + ); + } + + if (options.includeKey !== false) { + const encodedHost = Buffer.from("clerk.example.test$").toString("base64"); + await Bun.write(join(root, ".env"), `CLERK_PUBLISHABLE_KEY=pk_test_${encodedHost}\n`); + } + if (options.workspace) { + const workspace = join(root, "MyApp.xcworkspace"); + await mkdir(workspace, { recursive: true }); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + } + if (options.generated === "xcodegen") await Bun.write(join(root, "project.yml"), "name: MyApp\n"); + if (options.generated === "tuist") + await Bun.write(join(root, "Project.swift"), "import ProjectDescription\n"); +} + +/** Converts the classic fixture into the modern synchronized-root shape used by new Xcode apps. */ +export async function convertIOSFixtureToSynchronizedMissingEntitlements( + root: string, +): Promise { + const synchronizedRootId = "515151515151515151515151"; + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + const mainGroup = objects[IDS.mainGroup]!; + mainGroup.children = [ + ...(mainGroup.children as string[]).filter((id) => id !== IDS.entitlementsFile), + synchronizedRootId, + ]; + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "MyApp", + sourceTree: "", + }; + objects[IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + delete objects[IDS.entitlementsFile]; + for (const id of [IDS.targetDebug, IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + delete settings.CODE_SIGN_ENTITLEMENTS; + settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"] = "MyApp/MyApp.mac.entitlements"; + } + await writeFile(projectPath, buildPbxProject(project)); + await rm(join(root, "MyApp", "MyApp.entitlements"), { force: true }); +} + +async function digestEntry(root: string, path: string): Promise { + const info = await lstat(path); + const relativePath = relative(root, path).split("\\").join("/") || "."; + if (info.isSymbolicLink()) { + return [`l:${relativePath}:${info.mode}:${await readlink(path)}`]; + } + if (info.isDirectory()) { + const entries = (await readdir(path)).sort(); + const nested = await Promise.all( + entries.map(async (entry) => digestEntry(root, join(path, entry))), + ); + return [`d:${relativePath}:${info.mode}`, ...nested.flat()]; + } + const hash = new Bun.CryptoHasher("sha256").update(await readFile(path)).digest("hex"); + return [`f:${relativePath}:${info.mode}:${hash}`]; +} + +export async function treeDigest(root: string): Promise { + return digestEntry(root, root); +} + +export { IDS as IOS_FIXTURE_IDS }; diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts new file mode 100644 index 000000000..98017d765 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -0,0 +1,228 @@ +export type IOSDiagnosticSeverity = "info" | "warning" | "error"; + +export interface IOSSourceEvidence { + /** Project-root-relative path. */ + path: string; + objectId?: string; + keyPath?: string; +} + +export interface IOSDiagnostic { + code: + | "xcode.no-project" + | "xcode.malformed-project" + | "xcode.missing-project-file" + | "xcode.dangling-reference" + | "xcode.no-ios-app-target" + | "xcode.ambiguous-app-target" + | "xcode.target-not-found" + | "xcode.unresolved-build-setting" + | "xcode.conflicting-build-setting" + | "xcode.missing-entitlements" + | "xcode.unreadable-entitlements" + | "xcode.external-path" + | "xcode.generated-project" + | "xcode.incomplete-source-membership" + | "clerk.package-unattributed" + | "clerk.invalid-publishable-key" + | "clerk.conflicting-publishable-keys"; + severity: IOSDiagnosticSeverity; + message: string; + remedy?: string; + evidence: IOSSourceEvidence[]; +} + +export type IOSValueResolution = + | { state: "resolved"; value: string; evidence: IOSSourceEvidence[] } + | { + state: "unresolved"; + raw: string; + missingVariables: string[]; + evidence: IOSSourceEvidence[]; + } + | { state: "missing"; evidence: IOSSourceEvidence[] }; + +export interface IOSEntitlementsInspection { + path: string; + associatedDomains: string[]; + unresolvedAssociatedDomains: string[]; + applicationIdentifier?: string; + /** Literal prefix candidate from the source plist, validated against the Bundle ID. */ + literalAppIdentifierPrefix?: string; + teamIdentifier?: string; + signInWithApple: boolean; +} + +export interface IOSBuildConfiguration { + name: string; + bundleIdentifier: IOSValueResolution; + developmentTeam: IOSValueResolution; + entitlementsPath: IOSValueResolution; + deploymentTarget: IOSValueResolution; + entitlements?: IOSEntitlementsInspection; +} + +export type IOSPackageReference = + | { + kind: "remote"; + objectId: string; + repository: string; + requirement?: Record; + isClerk: boolean; + } + | { + kind: "local"; + objectId: string; + path: string; + isClerk: boolean; + }; + +export type IOSProductLinkState = "linked" | "declared" | "absent"; + +export interface IOSClerkPackageState { + package: "remote" | "local" | "unattributed" | "absent"; + clerkKit: IOSProductLinkState; + clerkKitUI: IOSProductLinkState; +} + +export type IOSPublishableKeyWiring = + | "inline-literal" + | "local-secrets-loader" + | "process-info-environment" + | "unknown"; + +export type IOSInlinePublishableKeyInspection = + | { + state: "valid"; + frontendApiHost: string; + instanceType: "development" | "production"; + } + | { state: "invalid" }; + +export interface IOSConfigureCallEvidence extends IOSSourceEvidence { + /** Redacted classification only; the key expression and value are never retained. */ + publishableKeyWiring: IOSPublishableKeyWiring; + /** Decoded metadata for a plain inline literal. The literal itself is never retained. */ + inlinePublishableKey?: IOSInlinePublishableKeyInspection; + /** Whether this call is a direct statement in the selected @main type's init(). */ + startupBinding: "app-init" | "unproven"; + /** Whether the referenced LocalSecrets symbol is the exact inspected runtime loader. */ + localSecretsRuntimeBinding?: "proven" | "unproven"; +} + +export interface IOSSwiftInspection { + sourceFilesScanned: number; + /** False when target membership was truncated or a member could not be read. */ + evidenceComplete: boolean; + entryPoints: IOSSourceEvidence[]; + importsClerkKit: IOSSourceEvidence[]; + importsClerkKitUI: IOSSourceEvidence[]; + configureCalls: IOSConfigureCallEvidence[]; + /** Exact target source that loads LocalSecrets.plist and CLERK_PUBLISHABLE_KEY. */ + localSecretsRuntimeBindings: IOSSourceEvidence[]; + environmentInjections: IOSSourceEvidence[]; + environmentConsumers: IOSSourceEvidence[]; + authFlowReferences: IOSSourceEvidence[]; + openURLHandlers: IOSSourceEvidence[]; + status: "complete" | "partial" | "absent" | "ambiguous"; +} + +export interface IOSRuntimeKeySink { + kind: "local-secrets-plist"; + /** Project-root-relative path. The publishable-key value is never exposed. */ + path: string; +} + +export interface IOSAppTarget { + id: string; + name: string; + productName?: string; + projectPath: string; + configurations: IOSBuildConfiguration[]; + packages: IOSClerkPackageState; + swift: IOSSwiftInspection; + /** Target-owned runtime destinations proven from the Xcode project graph. */ + runtimeKeySinks: IOSRuntimeKeySink[]; +} + +export interface IOSProjectInspection { + path: string; + pbxprojPath: string; + objectVersion?: string; + packages: IOSPackageReference[]; + appTargetIds: string[]; + diagnostics: IOSDiagnostic[]; +} + +export interface IOSWorkspaceInspection { + path: string; + projectPaths: string[]; +} + +export type IOSTargetSelection = + | { state: "selected"; targetId: string; targetName: string; projectPath: string } + | { + state: "ambiguous"; + candidates: Array<{ targetId: string; targetName: string; projectPath: string }>; + } + | { state: "not-found"; requested: string; candidates: string[] } + | { state: "none" }; + +export interface IOSLocalPublishableKeyInspection { + found: boolean; + source?: string; + frontendApiHost?: string; + instanceType?: "development" | "production"; + conflict: boolean; + candidateSources: string[]; + invalidSources: string[]; +} + +export interface IOSProjectInspectionResult { + schemaVersion: 1; + platform: "ios"; + /** Absolute invocation root. Paths nested below it are emitted relatively. */ + root: string; + workspaces: IOSWorkspaceInspection[]; + projects: IOSProjectInspection[]; + appTargets: IOSAppTarget[]; + selection: IOSTargetSelection; + localPublishableKey: IOSLocalPublishableKeyInspection; + generatedProject: "xcodegen" | "tuist" | null; + diagnostics: IOSDiagnostic[]; +} + +export type IOSSetupStepId = + | "select-target" + | "install-clerk-sdk" + | "configure-publishable-key" + | "inject-clerk-environment" + | "wire-auth-callbacks" + | "register-native-application" + | "enable-native-apple" + | "add-associated-domain" + | "add-authentication-flow" + | "verify-integration"; + +export type IOSSetupStepStatus = "satisfied" | "required" | "review" | "blocked"; + +export interface IOSSetupStep { + id: IOSSetupStepId; + title: string; + status: IOSSetupStepStatus; + automatable: boolean; + description: string; + links?: Array<{ kind: "dashboard" | "documentation"; url: string }>; + evidence: IOSSourceEvidence[]; +} + +export interface IOSSetupPlan { + schemaVersion: 1; + kind: "clerk-ios-setup"; + root: string; + status: "ready" | "action-required" | "blocked"; + selection: IOSTargetSelection; + summary: Record; + steps: IOSSetupStep[]; + diagnostics: IOSDiagnostic[]; +} From 1a9cb68e491b8b5f384f1b3c971d27669163cfb7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 21 Aug 2026 16:59:49 -0400 Subject: [PATCH 02/45] fix(init): mask XML comments without joining markup --- .../src/commands/init/ios/discovery.ts | 2 +- .../src/commands/init/ios/inspect.test.ts | 21 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index 0b83ceb38..11cc3cc02 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -235,7 +235,7 @@ function resolveWorkspaceLocation( * comment outright could synthesize markup from two otherwise inert fragments * (for example, `leRef>`). */ -function maskXMLComments(source: string): string { +export function maskXMLComments(source: string): string { const chunks: string[] = []; let cursor = 0; while (cursor < source.length) { diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 2f1d01c8a..44b3ad83d 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -300,6 +300,27 @@ struct MyApp: App { expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); + test("does not synthesize Run scheme markup across XML comments", async () => { + const root = await fixture({ includeKey: false }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemeKey = `pk_test_${Buffer.from("comment.clerk.example$").toString("base64")}`; + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + `Action>Action>`, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + }); + test("ignores a workspace scheme that references a different same-named project container", async () => { const root = await fixture({ includeKey: false, workspace: true }); const schemeDirectory = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 8a0f773a4..37157d5c8 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -12,6 +12,7 @@ import { import { discoverIOSContainers, inspectWorkspace, + maskXMLComments, pathIsSafelyWithinIOSRoot, relativeIOSPath, xmlAttribute, @@ -216,7 +217,7 @@ async function schemePublishableKeyCandidates( if (!(await file.exists()) || file.size > 2_000_000) continue; let xml: string; try { - xml = (await file.text()).replace(//g, ""); + xml = maskXMLComments(await file.text()); } catch { continue; } From 9cbea872253cdabc9b925778293eb028ea7956fa Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 20:51:35 -0400 Subject: [PATCH 03/45] fix(init): normalize plist imports in bundled CLI --- .../cli-core/src/commands/init/ios/inspect.ts | 6 +-- .../src/commands/init/ios/plist.test.ts | 37 +++++++++++++++++++ .../cli-core/src/commands/init/ios/plist.ts | 37 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/plist.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/plist.ts diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 37157d5c8..14869e5ce 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,6 +1,5 @@ import { readdir, readFile } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; -import plist from "@expo/plist"; import { parse as parsePbxProject } from "@bacons/xcode/json"; import { parseEnvFile } from "../../../lib/dotenv.ts"; import { decodePublishableKey } from "../../../lib/fapi.ts"; @@ -29,6 +28,7 @@ import { type PbxObject, type PbxObjects, } from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; import { inspectSwiftSources } from "./swift.ts"; import type { IOSAppTarget, @@ -335,7 +335,7 @@ async function readPublishableKeyCandidates( const file = Bun.file(path); if (!(await file.exists()) || file.size > 1_000_000) continue; try { - const parsed: unknown = plist.parse(await file.text()); + const parsed = parseIOSPlist(await file.text()); const value = isRecord(parsed) ? asString(parsed.CLERK_PUBLISHABLE_KEY) : undefined; if (value) { candidates.push({ @@ -666,7 +666,7 @@ async function inspectEntitlements( if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { throw new Error("binary plist"); } - const parsed: unknown = plist.parse(new TextDecoder().decode(bytes)); + const parsed = parseIOSPlist(new TextDecoder().decode(bytes)); if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); const applicationIdentifier = asString(parsed["application-identifier"]); diff --git a/packages/cli-core/src/commands/init/ios/plist.test.ts b/packages/cli-core/src/commands/init/ios/plist.test.ts new file mode 100644 index 000000000..fe3e6323f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plist.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeIOSPlistModule, parseIOSPlist } from "./plist.ts"; + +const xml = ` + + + + com.apple.developer.associated-domains + webcredentials:clerk.example.test + +`; + +describe("iOS plist adapter", () => { + test("parses XML through the source module shape", () => { + expect(parseIOSPlist(xml)).toEqual({ + "com.apple.developer.associated-domains": ["webcredentials:clerk.example.test"], + }); + }); + + test("normalizes direct and bundled CommonJS export shapes", () => { + const parser = { parse: (source: string) => ({ source }) }; + + expect(normalizeIOSPlistModule(parser).parse("direct")).toEqual({ source: "direct" }); + expect(normalizeIOSPlistModule({ default: parser }).parse("wrapped")).toEqual({ + source: "wrapped", + }); + expect(normalizeIOSPlistModule({ default: { default: parser } }).parse("nested")).toEqual({ + source: "nested", + }); + }); + + test("rejects an incompatible module shape", () => { + expect(() => normalizeIOSPlistModule({ default: {} })).toThrow( + "@expo/plist does not expose a compatible parser", + ); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/plist.ts b/packages/cli-core/src/commands/init/ios/plist.ts new file mode 100644 index 000000000..135d63b51 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plist.ts @@ -0,0 +1,37 @@ +import * as plistModule from "@expo/plist"; + +interface IOSPlistParser { + parse(source: string): unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * @expo/plist is CommonJS. Bun exposes it directly while running source, but + * can wrap the same export in one or more `default` properties when bundling + * the CLI. Resolve both representations before parsing any project plist. + */ +export function normalizeIOSPlistModule(module: unknown): IOSPlistParser { + let candidate = module; + + for (let depth = 0; depth < 4 && isRecord(candidate); depth += 1) { + const parse = candidate.parse; + if (typeof parse === "function") { + const receiver = candidate; + return { + parse: (source) => parse.call(receiver, source), + }; + } + candidate = candidate.default; + } + + throw new TypeError("@expo/plist does not expose a compatible parser"); +} + +const plist = normalizeIOSPlistModule(plistModule); + +export function parseIOSPlist(source: string): unknown { + return plist.parse(source); +} From 13827d1bcaf2a41a9771529edeb4abfdcd3848a1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 21:54:09 -0400 Subject: [PATCH 04/45] fix(init): reject resolved non-iOS targets --- .../commands/init/ios/build-settings.test.ts | 24 +++++++++++++++++++ .../src/commands/init/ios/build-settings.ts | 7 +++--- .../src/commands/init/ios/inspect.test.ts | 2 +- .../src/commands/init/ios/test-helpers.ts | 2 +- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 03c954b0f..f355d4ade 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -451,6 +451,30 @@ describe("inspectTargetBuildConfigurations", () => { expect(configurations[0]?.isIOS).toBe(false); }); + test("rejects resolved non-iOS targets despite a stale iOS deployment target", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "watchos", + SUPPORTED_PLATFORMS: "watchos watchsimulator", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + }, + }); + + expect(configurations[0]?.isIOS).toBe(false); + }); + + test("keeps targets when some non-iOS platform evidence remains unresolved", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "watchos", + SUPPORTED_PLATFORMS: "$(UNKNOWN_PLATFORMS)", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + }, + }); + + expect(configurations[0]?.isIOS).toBe(true); + }); + test("preserves dangling target configurations as blocking placeholders", async () => { const { configurations, diagnostics } = await inspectFixture({ targetConfigurationIds: ["target-debug", "missing-target-release"], diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 1a350262f..182e329c6 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -843,8 +843,6 @@ export async function inspectTargetBuildConfigurations(options: { const hasIOSPlatform = supportedPlatformsResolution.state === "resolved" && /iphone(?:os|simulator)/.test(supportedPlatforms); - const hasPositiveIOSEvidence = - deploymentTarget.state !== "missing" || hasIOSSDK || hasIOSPlatform; const hasUnknownPlatformEvidence = sdkRootResolution.state === "unresolved" || supportedPlatformsResolution.state === "unresolved"; @@ -853,8 +851,11 @@ export async function inspectTargetBuildConfigurations(options: { (supportedPlatformsResolution.state === "resolved" && supportedPlatforms !== "" && !hasIOSPlatform); + // SDKROOT and SUPPORTED_PLATFORMS describe the target platform directly. + // IPHONEOS_DEPLOYMENT_TARGET can remain as a stale setting on a non-iOS + // target, so it must not override resolved platform evidence. const explicitlyNonIOS = - !hasPositiveIOSEvidence && !hasUnknownPlatformEvidence && hasResolvedNonIOSEvidence; + !hasUnknownPlatformEvidence && !hasIOSSDK && !hasIOSPlatform && hasResolvedNonIOSEvidence; const model: IOSBuildConfiguration = { name, diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 44b3ad83d..a7b53c34e 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -168,7 +168,7 @@ describe("inspectIOSProject", () => { ]); }); - test("does not treat watchOS application products as iOS app candidates", async () => { + test("does not treat watchOS products with stale iOS settings as iOS app candidates", async () => { const root = await fixture({ secondTarget: "watchos" }); const inspection = await inspectIOSProject(root); diff --git a/packages/cli-core/src/commands/init/ios/test-helpers.ts b/packages/cli-core/src/commands/init/ios/test-helpers.ts index 322c03eaa..78453964b 100644 --- a/packages/cli-core/src/commands/init/ios/test-helpers.ts +++ b/packages/cli-core/src/commands/init/ios/test-helpers.ts @@ -61,7 +61,7 @@ function secondTargetObjects(platform: "ios" | "watchos"): string { const sourceName = isWatchOS ? "WatchAppApp.swift" : "AdminAppApp.swift"; const targetName = isWatchOS ? "MyApp Watch App" : "AdminApp"; const platformSettings = isWatchOS - ? 'PRODUCT_BUNDLE_IDENTIFIER = com.example.WatchApp; DEVELOPMENT_TEAM = ABCDE12345; SDKROOT = watchos; SUPPORTED_PLATFORMS = "watchos watchsimulator"; WATCHOS_DEPLOYMENT_TARGET = 10.0;' + ? 'PRODUCT_BUNDLE_IDENTIFIER = com.example.WatchApp; DEVELOPMENT_TEAM = ABCDE12345; SDKROOT = watchos; SUPPORTED_PLATFORMS = "watchos watchsimulator"; WATCHOS_DEPLOYMENT_TARGET = 10.0; IPHONEOS_DEPLOYMENT_TARGET = 17.0;' : 'PRODUCT_BUNDLE_IDENTIFIER = com.example.AdminApp; DEVELOPMENT_TEAM = ABCDE12345; IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";'; return ` ${IDS.secondGroup} = { isa = PBXGroup; children = ( ${IDS.secondAppFile}, ); path = ${directoryName}; sourceTree = ""; }; From 5672fcbc4ecbb5df39c38b419ac6036e74563cf2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 23:12:41 -0400 Subject: [PATCH 05/45] fix(init): ignore Swift regex literal evidence --- .../src/commands/init/ios/swift.test.ts | 110 +++++++++++++++++- .../cli-core/src/commands/init/ios/swift.ts | 104 ++++++++++++++++- 2 files changed, 207 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 6c774125e..456aee7bc 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { inspectSwiftSources, sanitizeSwiftSource } from "./swift.ts"; +import { + inspectSwiftSources, + sanitizeSwiftSource, + sanitizeSwiftSourceWithStatus, +} from "./swift.ts"; const temporaryDirectories: string[] = []; @@ -27,9 +31,113 @@ describe("sanitizeSwiftSource", () => { expect(sanitized).not.toContain(".environment"); expect(sanitized).toContain("@main struct RealApp"); }); + + test("removes bare, extended, multi-hash, and multiline regex literals", () => { + const source = [ + "let bare = /Clerk.shared.auth.signInWithApple()/", + "let extended = #/AuthView()/#", + "let internalSlash = #/path/to/Clerk.shared.auth.startHostedAuth()/#", + "let escapedDelimiter = #/prefix\\/#Clerk.shared.auth.signUp()/#", + "let multiHash = ###/Clerk.shared.handle(url)/###", + "let multiline = ##/", + " Clerk.shared.auth.signInWithPassword()", + "/##", + "let compactDivision = numerator/denominator/divisor", + "let spacedDivision = numerator / denominator / divisor", + ].join("\n"); + + const result = sanitizeSwiftSourceWithStatus(source); + + expect(result.complete).toBe(true); + expect(result.sanitizedSource).not.toContain("Clerk.shared"); + expect(result.sanitizedSource).not.toContain("AuthView"); + expect(result.sanitizedSource).toContain("let compactDivision = numerator/denominator/divisor"); + expect(result.sanitizedSource).toContain( + "let spacedDivision = numerator / denominator / divisor", + ); + expect(result.sanitizedSource.length).toBe(source.length); + expect([...result.sanitizedSource.matchAll(/\n/g)].map((match) => match.index)).toEqual( + [...source.matchAll(/\n/g)].map((match) => match.index), + ); + }); + + test("reports malformed and interpolation-like regex literals as incomplete", () => { + const malformed = sanitizeSwiftSourceWithStatus( + "let matcher = /Clerk.shared.auth.signInWithApple()\n@main struct RealApp: App {}", + ); + expect(malformed.complete).toBe(false); + expect(malformed.sanitizedSource).not.toContain("Clerk.shared"); + expect(malformed.sanitizedSource).toContain("@main struct RealApp"); + + const interpolated = sanitizeSwiftSourceWithStatus( + "let matcher = #/prefix\\#(value)Clerk.shared.auth.signInWithApple()/#", + ); + expect(interpolated.complete).toBe(false); + expect(interpolated.sanitizedSource).not.toContain("Clerk.shared"); + }); }); describe("inspectSwiftSources", () => { + test("ignores authentication symbols inside Swift regex literals", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Patterns.swift"); + await Bun.write( + path, + `import ClerkKit + import ClerkKitUI + let native = #/Clerk.shared.auth.signInWithApple()/# + let prebuilt = /AuthView()/`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "Patterns.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([]); + }); + + test("still records a real authentication call adjacent to a regex literal", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Authentication.swift"); + await Bun.write( + path, + `import ClerkKit + let matcher = #/Clerk.shared.auth.signInWithApple()/# + func authenticate() async throws { + try await Clerk.shared.auth.signInWithApple() + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "Authentication.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.authFlowReferences).toEqual([{ path: "Authentication.swift" }]); + }); + + test("marks source evidence incomplete for an unclosed regex literal", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Patterns.swift"); + await Bun.write( + path, + `import ClerkKit + let matcher = /Clerk.shared.auth.signInWithApple() + struct ContentView {}`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "Patterns.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.authFlowReferences).toEqual([]); + }); + test("records real Clerk evidence without retaining key expressions", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index ce727d10f..a40b0916d 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -16,17 +16,80 @@ function blankRange(chars: string[], start: number, end: number): void { } } +export interface SwiftSourceSanitization { + sanitizedSource: string; + complete: boolean; +} + +function hasExactHashes(chars: string[], start: number, hashCount: number): boolean { + if (hashCount === 0) return true; + for (let hash = 0; hash < hashCount; hash++) { + if (chars[start + hash] !== "#") return false; + } + return chars[start + hashCount] !== "#"; +} + +function isBareRegexOpening(chars: string[], index: number): boolean { + const first = chars[index + 1]; + if (first == null || first === " " || first === "\t" || first === "\n" || first === "\r") { + return false; + } + + if (index === 0) return true; + const previous = chars[index - 1]; + return /\s/.test(previous ?? "") || "([{,:;".includes(previous ?? ""); +} + +function consumeRegexLiteral( + chars: string[], + openingSlash: number, + hashCount: number, +): { end: number; complete: boolean } { + let cursor = openingSlash + 1; + + while (cursor < chars.length) { + if (hashCount === 0 && (chars[cursor] === "\n" || chars[cursor] === "\r")) { + return { end: cursor, complete: false }; + } + + if (chars[cursor] === "\\") { + let escapeHashes = 0; + while (chars[cursor + 1 + escapeHashes] === "#") escapeHashes++; + if (escapeHashes === hashCount && chars[cursor + 1 + escapeHashes] === "(") { + // Regex interpolation contains arbitrary Swift expressions. Without a + // full Swift parser, a delimiter inside the expression cannot be + // distinguished safely from the regex's closing delimiter. + return { end: chars.length, complete: false }; + } + cursor = Math.min(chars.length, cursor + 2); + continue; + } + + if (chars[cursor] === "/" && hasExactHashes(chars, cursor + 1, hashCount)) { + if (hashCount === 0 && (chars[cursor - 1] === " " || chars[cursor - 1] === "\t")) { + return { end: cursor + 1, complete: false }; + } + return { end: cursor + 1 + hashCount, complete: true }; + } + + cursor++; + } + + return { end: chars.length, complete: false }; +} + /** * Removes comments and string contents while preserving offsets and newlines. * This is intentionally a small lexer rather than a regex: Swift supports * nested block comments, multiline strings, and arbitrary raw-string hashes. */ -function sanitizeSwift(source: string, blankStrings: boolean): string { +function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSanitization { // Swift source offsets below are JavaScript UTF-16 indexes. Split into code // units so blanking an emoji or other astral character never shifts later // slices into the original source. const chars = source.split(""); let i = 0; + let complete = true; while (i < chars.length) { if (chars[i] === "/" && chars[i + 1] === "/") { @@ -52,13 +115,32 @@ function sanitizeSwift(source: string, blankStrings: boolean): string { i++; } } + if (depth > 0) complete = false; blankRange(chars, start, i); continue; } let hashCount = 0; while (chars[i + hashCount] === "#") hashCount++; - const quoteIndex = i + hashCount; + const delimiterIndex = i + hashCount; + + if (hashCount > 0 && chars[delimiterIndex] === "/") { + const literal = consumeRegexLiteral(chars, delimiterIndex, hashCount); + blankRange(chars, i, literal.end); + complete &&= literal.complete; + i = literal.end; + continue; + } + + if (hashCount === 0 && chars[i] === "/" && isBareRegexOpening(chars, i)) { + const literal = consumeRegexLiteral(chars, i, 0); + blankRange(chars, i, literal.end); + complete &&= literal.complete; + i = literal.end; + continue; + } + + const quoteIndex = delimiterIndex; if (chars[quoteIndex] !== '"') { i++; continue; @@ -68,6 +150,7 @@ function sanitizeSwift(source: string, blankStrings: boolean): string { const multiline = chars[quoteIndex] === '"' && chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; i = quoteIndex + (multiline ? 3 : 1); + let closed = false; while (i < chars.length) { const closesQuote = multiline @@ -82,6 +165,7 @@ function sanitizeSwift(source: string, blankStrings: boolean): string { } if (closesHashes) { i += quoteLength + hashCount; + closed = true; break; } } @@ -99,17 +183,22 @@ function sanitizeSwift(source: string, blankStrings: boolean): string { i++; } + if (!closed) complete = false; if (blankStrings) blankRange(chars, start, i); } - return chars.join(""); + return { sanitizedSource: chars.join(""), complete }; } export function sanitizeSwiftSource(source: string): string { + return sanitizeSwiftSourceWithStatus(source).sanitizedSource; +} + +export function sanitizeSwiftSourceWithStatus(source: string): SwiftSourceSanitization { return sanitizeSwift(source, true); } -function sourceWithoutComments(source: string): string { +function sourceWithoutComments(source: string): SwiftSourceSanitization { return sanitizeSwift(source, false); } @@ -1678,8 +1767,11 @@ export async function inspectSwiftSources( } sourceFilesScanned++; - const sanitized = withoutPreviewOnlyRegions(sanitizeSwiftSource(source)); - const uncommented = withoutPreviewOnlyRegions(sourceWithoutComments(source)); + const structuralSource = sanitizeSwiftSourceWithStatus(source); + const valueSource = sourceWithoutComments(source); + if (!structuralSource.complete || !valueSource.complete) evidenceComplete = false; + const sanitized = withoutPreviewOnlyRegions(structuralSource.sanitizedSource); + const uncommented = withoutPreviewOnlyRegions(valueSource.sanitizedSource); const evidence = { path: file.relativePath }; const importsKit = has( sanitized, From 5aa8c57bbe14656203e06e5fc48104dacb1dfd5f Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 08:29:52 -0400 Subject: [PATCH 06/45] fix(init): fail closed on skipped Xcode links --- .../src/commands/init/ios/discovery.ts | 18 +++++- .../src/commands/init/ios/inspect.test.ts | 59 ++++++++++++++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index 11cc3cc02..ac5f5631c 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -1,4 +1,4 @@ -import { readdir, realpath } from "node:fs/promises"; +import { readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import type { IOSWorkspaceInspection } from "./types.ts"; @@ -115,9 +115,23 @@ async function walkContainers( entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { - if (!entry.isDirectory()) continue; const absolutePath = resolve(directory, entry.name); + if (entry.isSymbolicLink()) { + if (IGNORED_DIRECTORIES.has(entry.name)) continue; + if (!state.includeHiddenDirectories && entry.name.startsWith(".")) continue; + try { + if ((await stat(absolutePath)).isDirectory()) state.complete = false; + } catch { + // A broken or unreadable link could have hidden a directory containing + // another Xcode container, so exhaustive ownership cannot be proven. + state.complete = false; + } + continue; + } + + if (!entry.isDirectory()) continue; + if (entry.name.endsWith(".xcodeproj")) { if (projects.size + workspaces.size >= MAX_DISCOVERED_CONTAINERS) { state.complete = false; diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index a7b53c34e..a7afda995 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { inspectWorkspace } from "./discovery.ts"; -import { inspectIOSProject } from "./inspect.ts"; +import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; +import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; @@ -19,7 +19,62 @@ afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); +describe("discoverIOSContainers", () => { + test("marks a skipped symlinked Xcode container incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-discovery-")); + temporaryDirectories.push(root); + const project = join(root, "Real.xcodeproj"); + await mkdir(project); + await symlink(project, join(root, "Linked.xcodeproj"), "dir"); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + + expect(discovery.projectPaths).toEqual([project]); + expect(discovery.complete).toBe(false); + }); + + test("marks symlinked and unreadable traversal paths incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-discovery-")); + temporaryDirectories.push(root); + const linkedDirectory = join(root, "LinkedProjects"); + const targetDirectory = join(root, "TargetProjects"); + await mkdir(targetDirectory); + await symlink(targetDirectory, linkedDirectory, "dir"); + await symlink(join(root, "MissingProjects"), join(root, "BrokenProjects"), "dir"); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + + expect(discovery.complete).toBe(false); + }); + + test("does not taint discovery for file links or ignored directory links", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-discovery-")); + temporaryDirectories.push(root); + const file = join(root, "Configuration.txt"); + const ignoredTarget = join(root, "IgnoredTarget"); + await Bun.write(file, "configuration"); + await mkdir(ignoredTarget); + await symlink(file, join(root, "LinkedConfiguration.txt"), "file"); + await symlink(ignoredTarget, join(root, "Pods"), "dir"); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + + expect(discovery.complete).toBe(true); + }); +}); + describe("inspectIOSProject", () => { + test("fails source ownership closed when a project-container link is skipped", async () => { + const root = await fixture({ complete: true }); + const project = join(root, "MyApp.xcodeproj"); + await symlink(project, join(root, "Linked.xcodeproj"), "dir"); + + const memberships = await inspectIOSSourceMembership(root); + + expect(memberships.length).toBeGreaterThan(0); + expect(memberships.every((membership) => !membership.complete)).toBe(true); + }); + test("inspects target settings, Clerk linkage, entitlements, and Swift setup", async () => { const root = await fixture({ complete: true, workspace: true }); const inspection = await inspectIOSProject(root); From f4588270445ee07255153cd63f5baf8f838a18e3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 13:21:26 -0400 Subject: [PATCH 07/45] fix(init): preserve concurrent file replacements --- .../init/ios/file-transaction.test.ts | 130 +++++--- .../src/commands/init/ios/file-transaction.ts | 312 +++++++++++------- 2 files changed, 288 insertions(+), 154 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts index 9af8b818a..090d049e9 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -152,83 +152,103 @@ describe("iOS existing-file transaction", () => { await expectNoTemporaryFiles(root); }); - test("keeps the public destination present and both crash states recoverable", async () => { + test("keeps both inodes recoverable across the exclusive install boundary", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); await writeFile(path, "original source\n"); const prepared = await mutation(path, "candidate source\n"); - let backupPath: string | undefined; + let claimPath: string | undefined; const observed: string[] = []; const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { - beforeExistingDestinationBackup: async (destinationPath) => { + beforeExistingDestinationClaim: async (destinationPath) => { expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); - observed.push("before-backup"); + observed.push("before-claim"); }, - afterExistingDestinationBackup: async (destinationPath, createdBackupPath) => { - backupPath = createdBackupPath; - expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); - expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); + afterExistingDestinationClaim: async (destinationPath, createdClaimPath) => { + claimPath = createdClaimPath; + await expect(lstat(destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(createdClaimPath, "utf8")).toBe("original source\n"); const stagedPath = (await readdir(root)).find((name) => name.endsWith(".tmp")); if (!stagedPath) throw new Error("expected the staged candidate to remain recoverable"); expect(await readFile(join(root, stagedPath), "utf8")).toBe("candidate source\n"); - observed.push("backup-before-rename"); + observed.push("original-claimed"); }, - beforeExistingDestinationReplace: async (destinationPath, createdBackupPath) => { - expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); - expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); - observed.push("immediately-before-rename"); + beforeExistingDestinationInstall: async (destinationPath, createdClaimPath) => { + await expect(lstat(destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(createdClaimPath, "utf8")).toBe("original source\n"); + observed.push("before-exclusive-install"); }, - afterExistingDestinationReplace: async (destinationPath, createdBackupPath) => { + afterExistingDestinationInstall: async (destinationPath, createdClaimPath) => { expect(await readFile(destinationPath, "utf8")).toBe("candidate source\n"); - expect(await readFile(createdBackupPath, "utf8")).toBe("original source\n"); - expect((await readdir(root)).filter((name) => name.endsWith(".tmp"))).toEqual([]); - observed.push("candidate-and-backup-after-rename"); + expect(await readFile(createdClaimPath, "utf8")).toBe("original source\n"); + const stagedPath = (await readdir(root)).find((name) => name.endsWith(".tmp")); + if (!stagedPath) throw new Error("expected the candidate recovery link to remain"); + expect((await lstat(join(root, stagedPath))).ino).toBe((await lstat(destinationPath)).ino); + observed.push("candidate-and-original-recoverable"); }, }); expect(result).toEqual({ status: "applied" }); expect(observed).toEqual([ - "before-backup", - "backup-before-rename", - "immediately-before-rename", - "candidate-and-backup-after-rename", + "before-claim", + "original-claimed", + "before-exclusive-install", + "candidate-and-original-recoverable", ]); - expect(backupPath).toBeDefined(); - await expect(lstat(backupPath!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(claimPath).toBeDefined(); + await expect(lstat(claimPath!)).rejects.toMatchObject({ code: "ENOENT" }); expect(await readFile(path, "utf8")).toBe("candidate source\n"); await expectNoTemporaryFiles(root); }); - test("atomically restores the verified backup without an absent destination window", async () => { + test("restores the verified original through an exclusive rollback install", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); await writeFile(path, "original source\n"); const prepared = await mutation(path, "candidate source\n"); - let backupPath: string | undefined; + let originalClaimPath: string | undefined; + let candidateClaimPath: string | undefined; const result = await applyIOSExistingFileTransaction([prepared], [async () => false], { - afterExistingDestinationBackup: (_destinationPath, createdBackupPath) => { - backupPath = createdBackupPath; + afterExistingDestinationClaim: (_destinationPath, createdClaimPath) => { + originalClaimPath = createdClaimPath; }, - beforeRollbackDestinationReplace: async (destinationPath, originalSourcePath) => { + beforeRollbackDestinationClaim: async (destinationPath) => { expect(await readFile(destinationPath, "utf8")).toBe("candidate source\n"); - expect(backupPath).toBe(originalSourcePath); + }, + afterRollbackDestinationClaim: async (destinationPath, createdClaimPath) => { + candidateClaimPath = createdClaimPath; + await expect(lstat(destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(createdClaimPath, "utf8")).toBe("candidate source\n"); + }, + beforeRollbackDestinationInstall: async ( + destinationPath, + originalSourcePath, + createdCandidateClaimPath, + ) => { + await expect(lstat(destinationPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(originalClaimPath).toBe(originalSourcePath); + expect(candidateClaimPath).toBe(createdCandidateClaimPath); expect(await readFile(originalSourcePath, "utf8")).toBe("original source\n"); }, - afterRollbackDestinationReplace: async (destinationPath) => { + afterRollbackDestinationInstall: async (destinationPath) => { expect(await readFile(destinationPath, "utf8")).toBe("original source\n"); - expect(backupPath).toBeDefined(); - await expect(lstat(backupPath!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(originalClaimPath).toBeDefined(); + expect(candidateClaimPath).toBeDefined(); + expect(await readFile(originalClaimPath!, "utf8")).toBe("original source\n"); + expect(await readFile(candidateClaimPath!, "utf8")).toBe("candidate source\n"); }, }); expect(result).toEqual({ status: "rolled-back" }); expect(await readFile(path, "utf8")).toBe("original source\n"); + await expect(lstat(originalClaimPath!)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(candidateClaimPath!)).rejects.toMatchObject({ code: "ENOENT" }); await expectNoTemporaryFiles(root); }); - test("rolls back an after-rename failure without exposing candidate source bytes", async () => { + test("rolls back an after-install failure without exposing candidate source bytes", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); const sensitiveCandidate = "pk_test_candidate_must_not_escape"; @@ -238,8 +258,8 @@ describe("iOS existing-file transaction", () => { let caught: unknown; try { await applyIOSExistingFileTransaction([prepared], [async () => true], { - afterExistingDestinationReplace: () => { - throw new Error("forced after-rename failure"); + afterExistingDestinationInstall: () => { + throw new Error("forced after-install failure"); }, }); } catch (error) { @@ -456,7 +476,7 @@ describe("iOS existing-file transaction", () => { await expectNoTemporaryFiles(root); }); - test("preserves a replacement visible before the final atomic commit check", async () => { + test("preserves an editor replacement that wins the commit install boundary", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); const replacementPath = join(root, "external-replacement.tmp"); @@ -468,7 +488,7 @@ describe("iOS existing-file transaction", () => { const replacementIdentity = await lstat(replacementPath); const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { - beforeExistingDestinationReplace: async (destinationPath) => { + beforeExistingDestinationInstall: async (destinationPath) => { await rename(replacementPath, destinationPath); }, }); @@ -480,6 +500,36 @@ describe("iOS existing-file transaction", () => { await expectNoTemporaryFiles(root); }); + test("rolls back earlier files when an editor wins a later install boundary", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "First.swift"); + const secondPath = join(root, "Second.swift"); + const replacementPath = join(root, "external-second.tmp"); + await writeFile(firstPath, "original first\n"); + await writeFile(secondPath, "original second\n"); + await chmod(secondPath, 0o640); + await writeFile(replacementPath, "newer editor second\n"); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + const prepared = await Promise.all([ + mutation(firstPath, "candidate first\n"), + mutation(secondPath, "candidate second\n"), + ]); + + const result = await applyIOSExistingFileTransaction(prepared, [async () => true], { + beforeExistingDestinationInstall: async (destinationPath) => { + if (destinationPath === secondPath) await rename(replacementPath, destinationPath); + }, + }); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(firstPath, "utf8")).toBe("original first\n"); + expect(await readFile(secondPath, "utf8")).toBe("newer editor second\n"); + expect((await lstat(secondPath)).ino).toBe(replacementIdentity.ino); + expect((await lstat(secondPath)).mode & 0o7777).toBe(0o600); + await expectNoTemporaryFiles(root); + }); + test("preserves writes through an already-open original file descriptor", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); @@ -605,7 +655,7 @@ describe("iOS existing-file transaction", () => { await expectRecoverableClaimedOriginals(root, ["original\n"]); }); - test("preserves a replacement visible before the final atomic rollback check", async () => { + test("preserves an editor replacement that wins the rollback install boundary", async () => { const root = await temporaryRoot(); const path = join(root, "App.swift"); const replacementPath = join(root, "external-replacement.tmp"); @@ -619,7 +669,7 @@ describe("iOS existing-file transaction", () => { let caught: unknown; try { await applyIOSExistingFileTransaction([prepared], [async () => false], { - beforeRollbackDestinationReplace: async (destinationPath) => { + beforeRollbackDestinationInstall: async (destinationPath) => { await rename(replacementPath, destinationPath); }, }); @@ -727,7 +777,7 @@ describe("iOS create-file transaction", () => { let caught: unknown; try { await applyIOSFileTransaction(prepared, [async () => true], { - beforeExistingDestinationReplace: async () => { + beforeExistingDestinationInstall: async () => { await rename(synchronizedRoot, displacedRoot); await mkdir(synchronizedRoot); }, diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts index 3aab961c8..37a6a2a93 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -97,12 +97,18 @@ class IOSFileTransactionOwnershipError extends Error {} * @internal */ export interface IOSFileTransactionTestHooks { - beforeExistingDestinationBackup?: (path: string) => void | Promise; - afterExistingDestinationBackup?: (path: string, backupPath: string) => void | Promise; - beforeExistingDestinationReplace?: (path: string, backupPath: string) => void | Promise; - afterExistingDestinationReplace?: (path: string, backupPath: string) => void | Promise; - beforeRollbackDestinationReplace?: (path: string, backupPath: string) => void | Promise; - afterRollbackDestinationReplace?: (path: string) => void | Promise; + beforeExistingDestinationClaim?: (path: string) => void | Promise; + afterExistingDestinationClaim?: (path: string, claimPath: string) => void | Promise; + beforeExistingDestinationInstall?: (path: string, claimPath: string) => void | Promise; + afterExistingDestinationInstall?: (path: string, claimPath: string) => void | Promise; + beforeRollbackDestinationClaim?: (path: string) => void | Promise; + afterRollbackDestinationClaim?: (path: string, claimPath: string) => void | Promise; + beforeRollbackDestinationInstall?: ( + path: string, + originalSourcePath: string, + candidateClaimPath: string, + ) => void | Promise; + afterRollbackDestinationInstall?: (path: string) => void | Promise; } export function hashIOSFileBytes(value: string | Uint8Array): string { @@ -155,6 +161,21 @@ async function readRegularFileIdentity(path: string): Promise { + try { + const beforeRead = await readRegularFileIdentity(path); + if (!beforeRead) return undefined; + const hash = hashIOSFileBytes(await readFile(path)); + const afterRead = await readRegularFileIdentity(path); + if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; + return { identity: afterRead, hash }; + } catch { + return undefined; + } +} + async function readPathIdentity(path: string): Promise { try { const info = await lstat(path); @@ -301,50 +322,6 @@ type ClaimDestinationResult = | { status: "claimed"; claim: ClaimedDestination } | { status: "stale" }; -/** - * Hard-links the authorized destination to a unique same-directory recovery - * path without ever removing the public destination. The backup remains until - * aggregate postvalidation succeeds or rollback atomically restores it. - */ -async function backupDestination( - destinationPath: string, - expectedIdentity: FileIdentity, - expectedHash: string, -): Promise { - const backupPath = transactionSiblingPath(destinationPath, "claimed"); - try { - await link(destinationPath, backupPath); - } catch (error) { - if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; - throw error; - } - - const linkedIdentity = await readPathIdentity(backupPath); - if (!linkedIdentity) return { status: "stale" }; - const backup: ClaimedDestination = { - path: backupPath, - present: true, - identity: linkedIdentity, - }; - const backupIdentity = await readRegularFileIdentity(backupPath); - if (!backupIdentity) { - await removeClaimedPath(backup); - return { status: "stale" }; - } - const backupMatches = - identitiesMatch(backupIdentity, expectedIdentity) && - (await fileMatchesIdentityAndHash(backupPath, expectedIdentity, expectedHash)); - const destinationMatches = await fileMatchesIdentityAndHash( - destinationPath, - expectedIdentity, - expectedHash, - ); - if (backupMatches && destinationMatches) return { status: "claimed", claim: backup }; - - await removeClaimedPath(backup); - return { status: "stale" }; -} - /** * Moves an existing destination to a unique same-directory name, then proves * which inode was moved. Unlike an overwriting rename, this never destroys a @@ -383,6 +360,40 @@ async function claimDestination( return { status: "stale" }; } +async function linkOwnedSourceWithoutClobber( + sourcePath: string, + sourceIdentity: FileIdentity, + sourceHash: string, + destinationPath: string, +): Promise<"linked" | "occupied"> { + if (!(await fileMatchesIdentityAndHash(sourcePath, sourceIdentity, sourceHash))) { + throw new IOSFileTransactionOwnershipError( + "a transaction source changed before it could be installed", + ); + } + try { + await link(sourcePath, destinationPath); + } catch (error) { + if (isFileSystemError(error, "EEXIST")) return "occupied"; + throw error; + } + + const destinationIdentity = await readRegularFileIdentity(destinationPath); + const sourceAfterLink = await readRegularFileIdentity(sourcePath); + if ( + !destinationIdentity || + !sourceAfterLink || + !identitiesMatch(destinationIdentity, sourceIdentity) || + !identitiesMatch(sourceAfterLink, sourceIdentity) || + !(await fileMatchesIdentityAndHash(destinationPath, sourceIdentity, sourceHash)) + ) { + throw new IOSFileTransactionOwnershipError( + "an exclusively installed transaction source could not be identified", + ); + } + return "linked"; +} + function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { return "kind" in mutation && mutation.kind === "create"; } @@ -654,10 +665,18 @@ async function createdCandidateIsUntouched(item: StagedMutation): Promise { if (isCreateMutation(item.mutation)) return createdCandidateIsUntouched(item); if (!item.committedIdentity) return false; - return fileMatchesIdentityAndHash( - item.mutation.path, - item.committedIdentity, - item.mutation.candidateHash, + if ( + !(await fileMatchesIdentityAndHash( + item.mutation.path, + item.committedIdentity, + item.mutation.candidateHash, + )) + ) { + return false; + } + return ( + !item.temporaryPresent || + fileMatchesIdentityAndHash(item.temporaryPath, item.stagedIdentity, item.mutation.candidateHash) ); } @@ -810,61 +829,108 @@ async function rollbackCommitted( } const originalBackup = item.claimedOriginal?.present ? item.claimedOriginal : undefined; const originalSourcePath = originalBackup?.path ?? rollback.temporaryPath; - const originalSourceIdentity = await readRegularFileIdentity(originalSourcePath); + const originalSource = await readRegularFileIdentityAndHash(originalSourcePath); if ( - !originalSourceIdentity || - (originalBackup && !sameFile(originalSourceIdentity, originalBackup.identity)) || + !originalSource || + (originalBackup && !sameFile(originalSource.identity, originalBackup.identity)) || (!originalBackup && - !(await fileMatchesIdentityAndHash( - originalSourcePath, - rollback.stagedIdentity, - item.mutation.originalHash, - ))) + (!identitiesMatch(originalSource.identity, rollback.stagedIdentity) || + originalSource.hash !== item.mutation.originalHash)) ) { throw new IOSFileTransactionOwnershipError( "the original iOS file could not be identified during rollback", ); } - await hooks.beforeRollbackDestinationReplace?.(rollback.mutation.path, originalSourcePath); - if (!(await committedCandidateIsUntouched(item))) { + const originalSourceIdentity = originalSource.identity; + const originalSourceHash = originalSource.hash; + // Rollback uses the same non-overwriting boundary as commit: preserve + // the actual public inode first, then restore through an exclusive link. + await hooks.beforeRollbackDestinationClaim?.(rollback.mutation.path); + const candidateIdentity = item.committedIdentity ?? item.stagedIdentity; + const candidateClaimResult = await claimDestination( + rollback.mutation.path, + candidateIdentity, + item.mutation.candidateHash, + ); + if (candidateClaimResult.status === "stale") { throw transactionError( "rollback-failed", "The iOS file transaction changed again during rollback; newer bytes were preserved.", ); } - const sourceIdentityBeforeRename = await readRegularFileIdentity(originalSourcePath); - if ( - !sourceIdentityBeforeRename || - !sameFile(sourceIdentityBeforeRename, originalSourceIdentity) - ) { - throw new IOSFileTransactionOwnershipError( - "the original iOS file changed before atomic rollback", + const candidateClaim = candidateClaimResult.claim; + rollbackClaims.push({ claim: candidateClaim, mutation: item.mutation }); + let sourceInstalled = false; + try { + await hooks.afterRollbackDestinationClaim?.(rollback.mutation.path, candidateClaim.path); + if ( + !(await fileMatchesIdentityAndHash( + originalSourcePath, + originalSourceIdentity, + originalSourceHash, + )) + ) { + throw new IOSFileTransactionOwnershipError( + "the original iOS file changed before rollback installation", + ); + } + await hooks.beforeRollbackDestinationInstall?.( + rollback.mutation.path, + originalSourcePath, + candidateClaim.path, ); - } - // This final ownership check and the atomic rename are the standard - // replacement linearization boundary. A writer that wins afterward is - // ordered before this rollback; portable Node APIs do not provide an - // inode-conditional rename primitive. - await rename(originalSourcePath, rollback.mutation.path); - if (originalBackup) originalBackup.present = false; - else rollback.temporaryPresent = false; - await hooks.afterRollbackDestinationReplace?.(rollback.mutation.path); - const restoredIdentity = await readRegularFileIdentity(rollback.mutation.path); - if ( - !restoredIdentity || - !sameFile(restoredIdentity, originalSourceIdentity) || - (!originalBackup && + const installResult = await linkOwnedSourceWithoutClobber( + originalSourcePath, + originalSourceIdentity, + originalSourceHash, + rollback.mutation.path, + ); + if (installResult === "occupied") { + throw transactionError( + "rollback-failed", + "The iOS file transaction changed again during rollback; newer bytes were preserved.", + ); + } + sourceInstalled = true; + await hooks.afterRollbackDestinationInstall?.(rollback.mutation.path); + const restoredIdentity = await readRegularFileIdentity(rollback.mutation.path); + if ( + !restoredIdentity || + !sameFile(restoredIdentity, originalSourceIdentity) || !(await fileMatchesIdentityAndHash( rollback.mutation.path, originalSourceIdentity, - item.mutation.originalHash, - ))) - ) { - throw new IOSFileTransactionOwnershipError( - "the restored iOS file did not match its rollback source", - ); + originalSourceHash, + )) + ) { + throw new IOSFileTransactionOwnershipError( + "the restored iOS file did not match its rollback source", + ); + } + await removeClaimedPath(candidateClaim, { + expectedHash: item.mutation.candidateHash, + expectedMode: item.mutation.mode, + }); + if (originalBackup) { + await removeClaimedPath(originalBackup, { + expectedHash: originalSourceHash, + expectedMode: originalSourceIdentity.mode, + }); + } + await syncDirectory(dirname(rollback.mutation.path)); + } catch (error) { + if (!sourceInstalled && (await pathIsAbsent(rollback.mutation.path))) { + try { + await restoreClaimWithoutClobber(candidateClaim, rollback.mutation.path); + } catch (restoreError) { + throw new IOSFileTransactionOwnershipError( + "the claimed candidate could not be restored after rollback stopped", + { cause: aggregateCause([error, restoreError]) }, + ); + } + } + throw error; } - await syncDirectory(dirname(rollback.mutation.path)); } await cleanupStaged(rollbackFiles); } catch (error) { @@ -1006,25 +1072,26 @@ export async function applyIOSFileTransaction( if (!expectedIdentity) { throw new Error("an existing iOS file identity was missing during commit"); } - await hooks.beforeExistingDestinationBackup?.(item.mutation.path); - const backupResult = await backupDestination( + // Portable Node APIs do not expose an inode-conditional replacement. + // Move whichever inode actually owns the destination into recovery, + // verify it, then link the candidate only while the public path is + // absent. A concurrent writer therefore wins with EEXIST instead of + // being overwritten. + await hooks.beforeExistingDestinationClaim?.(item.mutation.path); + const claimResult = await claimDestination( item.mutation.path, expectedIdentity, item.mutation.originalHash, ); - if (backupResult.status === "stale") { + if (claimResult.status === "stale") { stale = true; break; } - item.claimedOriginal = backupResult.claim; - await hooks.afterExistingDestinationBackup?.(item.mutation.path, item.claimedOriginal.path); - await hooks.beforeExistingDestinationReplace?.( - item.mutation.path, - item.claimedOriginal.path, - ); + item.claimedOriginal = claimResult.claim; + await hooks.afterExistingDestinationClaim?.(item.mutation.path, item.claimedOriginal.path); if ( !(await createParentsStillMatch(prepared)) || - !(await originalStateStillMatches(item.mutation, initialIdentities)) || + !(await claimedOriginalIsUntouched(item)) || !(await fileMatchesIdentityAndHash( item.temporaryPath, item.stagedIdentity, @@ -1033,23 +1100,40 @@ export async function applyIOSFileTransaction( ) { throw new IOSFileTransactionStaleError(); } - // The original remains available through the verified hard-link - // backup while this same-directory rename atomically replaces the - // public destination. There is no path-absence window. The final - // ownership check and rename form the standard replacement - // linearization boundary; portable Node APIs do not provide an - // inode-conditional rename primitive. - await rename(item.temporaryPath, item.mutation.path); - item.temporaryPresent = false; + await hooks.beforeExistingDestinationInstall?.( + item.mutation.path, + item.claimedOriginal.path, + ); + if ( + !(await createParentsStillMatch(prepared)) || + !(await claimedOriginalIsUntouched(item)) + ) { + throw new IOSFileTransactionStaleError(); + } + const installResult = await linkOwnedSourceWithoutClobber( + item.temporaryPath, + item.stagedIdentity, + item.mutation.candidateHash, + item.mutation.path, + ); + if (installResult === "occupied") { + await removeClaimedPath(item.claimedOriginal, { + expectedHash: item.mutation.originalHash, + expectedMode: item.mutation.mode, + }); + await syncDirectory(dirname(item.mutation.path)); + stale = true; + break; + } } - // A successful create link or existing-file rename proves that the + // A successful exclusive link proves that the // destination referred to the staged inode at the commit linearization // point. Record ownership synchronously before exposing it to rollback. item.committedIdentity = item.stagedIdentity; committed.push(item); committedThisItem = true; if (!isCreateMutation(item.mutation) && item.claimedOriginal) { - await hooks.afterExistingDestinationReplace?.( + await hooks.afterExistingDestinationInstall?.( item.mutation.path, item.claimedOriginal.path, ); @@ -1070,10 +1154,10 @@ export async function applyIOSFileTransaction( let effectiveError = error; if (!committedThisItem && item.claimedOriginal?.present) { try { - await removeClaimedPath(item.claimedOriginal); + await restoreClaimWithoutClobber(item.claimedOriginal, item.mutation.path); } catch (cleanupError) { effectiveError = new IOSFileTransactionOwnershipError( - "an original-file backup could not be released after commit stopped", + "a claimed original could not be restored after commit stopped", { cause: aggregateCause([error, cleanupError]) }, ); } From f8c80ff6c1e2c85fb1ea02be9ee2b5fb36f47e05 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 13:15:05 -0400 Subject: [PATCH 08/45] fix(init): inspect x86_64 simulator settings --- .../commands/init/ios/build-settings.test.ts | 86 ++++++++++++++++++- .../src/commands/init/ios/build-settings.ts | 16 ++-- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index f355d4ade..2866b431a 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -266,24 +266,62 @@ describe("inspectTargetBuildConfigurations", () => { expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ state: "unresolved", - missingVariables: ["sdk-conditioned build setting"], + missingVariables: ["sdk/architecture-conditioned build setting"], }); expect(diagnostics).toContainEqual( expect.objectContaining({ code: "xcode.conflicting-build-setting", message: expect.stringContaining( - "iphoneos=com.example.Device, iphonesimulator=com.example.Simulator", + "iphoneos/arm64=com.example.Device, iphonesimulator/arm64=com.example.Simulator, iphonesimulator/x86_64=com.example.Simulator", ), }), ); }); - test("accepts matching device and simulator conditional build settings", async () => { + test.each([ + [ + "PRODUCT_BUNDLE_IDENTIFIER", + "bundleIdentifier", + "com.example.Native", + "com.example.IntelSimulator", + ], + ["DEVELOPMENT_TEAM", "developmentTeam", "ABCDE12345", "ZYXWV98765"], + [ + "CODE_SIGN_ENTITLEMENTS", + "entitlementsPath", + "Example.entitlements", + "IntelSimulator.entitlements", + ], + ] as const)( + "fails closed when x86_64 simulator %s differs", + async (setting, modelKey, sharedValue, x86Value) => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + [setting]: sharedValue, + [`${setting}[sdk=iphonesimulator*][arch=x86_64]`]: x86Value, + }, + }); + + expect(configurations[0]?.model[modelKey]).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.conflicting-build-setting", + message: expect.stringContaining(`iphonesimulator/x86_64=${x86Value}`), + }), + ); + }, + ); + + test("accepts matching device and simulator architecture build settings", async () => { const { configurations } = await inspectFixture({ targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "com.example.Base", "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]": "com.example.Native", - "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]": "com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*][arch=arm64]": "com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*][arch=x86_64]": "com.example.Native", }, }); @@ -293,6 +331,46 @@ describe("inspectTargetBuildConfigurations", () => { }); }); + test("fails closed on x86_64 simulator differences from xcconfig", async () => { + const { configurations, diagnostics } = await inspectFixture({ + xcconfig: [ + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*][arch=x86_64] = com.example.IntelSimulator", + ].join("\n"), + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.conflicting-build-setting", + message: expect.stringContaining("iphonesimulator/x86_64=com.example.IntelSimulator"), + }), + ); + }); + + test("ignores simulator architecture differences for device-only targets", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + SUPPORTED_PLATFORMS: "iphoneos", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*][arch=x86_64]": + "com.example.IntelSimulator", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Device", + }); + expect(diagnostics).not.toContainEqual( + expect.objectContaining({ code: "xcode.conflicting-build-setting" }), + ); + }); + test("preserves the textual order of xcconfig assignments and includes", async () => { const includeLast = await inspectFixture({ xcconfig: [ diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 182e329c6..367c1d49f 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -30,9 +30,9 @@ const INSPECTED_BUILD_SETTING_KEYS = [ const INSPECTED_BUILD_SETTINGS = new Set(INSPECTED_BUILD_SETTING_KEYS); interface BuildContext { - label: "iphoneos" | "iphonesimulator"; + label: "iphoneos/arm64" | "iphonesimulator/arm64" | "iphonesimulator/x86_64"; sdk: "iphoneos" | "iphonesimulator"; - arch: "arm64"; + arch: "arm64" | "x86_64"; } interface BuildSettingsEvaluation { @@ -46,8 +46,9 @@ type XCConfigOperation = | { kind: "setting"; key: string; value: string; conditions?: XCConfigCondition[] }; const BUILD_CONTEXTS: BuildContext[] = [ - { label: "iphoneos", sdk: "iphoneos", arch: "arm64" }, - { label: "iphonesimulator", sdk: "iphonesimulator", arch: "arm64" }, + { label: "iphoneos/arm64", sdk: "iphoneos", arch: "arm64" }, + { label: "iphonesimulator/arm64", sdk: "iphonesimulator", arch: "arm64" }, + { label: "iphonesimulator/x86_64", sdk: "iphonesimulator", arch: "x86_64" }, ]; interface XCConfigCondition { @@ -636,10 +637,11 @@ function resolveSettingAcrossContexts( addDiagnosticOnce(diagnostics, { code: "xcode.conflicting-build-setting", severity: "warning", - message: `${targetName} ${configurationName} has different ${key} values by SDK: ${variants + message: `${targetName} ${configurationName} has different ${key} values by SDK and architecture: ${variants .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) .join(", ")}`, - remedy: "Make device and simulator values consistent or select the intended SDK explicitly.", + remedy: + "Make device and simulator architecture values consistent or select the intended SDK and architecture explicitly.", evidence: variants.flatMap(({ resolution }) => resolution.evidence), }); @@ -649,7 +651,7 @@ function resolveSettingAcrossContexts( .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) .join("; "), missingVariables: unique([ - "sdk-conditioned build setting", + "sdk/architecture-conditioned build setting", ...variants.flatMap(({ resolution }) => resolution.state === "unresolved" ? resolution.missingVariables : [], ), From 833fab6bd3069c753318daab03e668b8ac1bbf1a Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 14:13:19 -0400 Subject: [PATCH 09/45] fix(init): preserve entitlement build contexts --- .../src/commands/init/ios/build-settings.ts | 64 ++++++- .../src/commands/init/ios/inspect.test.ts | 156 ++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 58 ++++--- 3 files changed, 249 insertions(+), 29 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 367c1d49f..aa98be9c6 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -27,7 +27,6 @@ const INSPECTED_BUILD_SETTING_KEYS = [ "SDKROOT", "SUPPORTED_PLATFORMS", ] as const; -const INSPECTED_BUILD_SETTINGS = new Set(INSPECTED_BUILD_SETTING_KEYS); interface BuildContext { label: "iphoneos/arm64" | "iphonesimulator/arm64" | "iphonesimulator/x86_64"; @@ -39,6 +38,10 @@ interface BuildSettingsEvaluation { settings: Record; /** Unknown inputs are tracked independently for each inspected setting. */ settingTaints: Map; + /** Unknown inputs that may define any setting not yet seen by the inspector. */ + globalTaints: string[]; + /** Literal assignments after the most recent global taint are authoritative. */ + globalTaintOverrides: Set; } type XCConfigOperation = @@ -85,10 +88,14 @@ function cloneEvaluation(evaluation: BuildSettingsEvaluation): BuildSettingsEval return { settings: { ...evaluation.settings }, settingTaints: cloneSettingTaints(evaluation.settingTaints), + globalTaints: [...evaluation.globalTaints], + globalTaintOverrides: new Set(evaluation.globalTaintOverrides), }; } function taintInspectedSettings(evaluation: BuildSettingsEvaluation, taint: string): void { + evaluation.globalTaints = unique([...evaluation.globalTaints, taint]); + evaluation.globalTaintOverrides.clear(); for (const key of INSPECTED_BUILD_SETTING_KEYS) { addSettingTaint(evaluation.settingTaints, key, taint); } @@ -100,17 +107,28 @@ function applyEvaluatedSetting( value: string, conditions: XCConfigCondition[] | undefined, ): void { + const retainedInheritedOverride = + evaluation.globalTaintOverrides.has(key) && onlyUsesInheritedBuildSettingVariables(value); applySettings(evaluation.settings, { [key]: value }); - if (!INSPECTED_BUILD_SETTINGS.has(key)) return; // An unconditional literal assignment fully overrides any earlier unknown // include for this setting. Inherited or variable-derived values may still // depend on the skipped input, so retain their existing taint. if ((conditions?.length ?? 0) === 0 && !hasBuildSettingVariable(value)) { evaluation.settingTaints.delete(key); + evaluation.globalTaintOverrides.add(key); + } else if (!retainedInheritedOverride) { + evaluation.globalTaintOverrides.delete(key); } } +function onlyUsesInheritedBuildSettingVariables(value: string): boolean { + const variables = [...value.matchAll(/\$\(([^)]+)\)|\$\{([^}]+)\}/g)].map((match) => + String(match[1] ?? match[2]).toLowerCase(), + ); + return variables.length > 0 && variables.every((variable) => variable === "inherited"); +} + function hasBuildSettingVariable(value: string): boolean { BUILD_SETTING_VARIABLE.lastIndex = 0; const result = BUILD_SETTING_VARIABLE.test(value); @@ -189,7 +207,12 @@ async function readXCConfigSettings( configuration: string, context: BuildContext, diagnostics: IOSDiagnostic[], - inherited: BuildSettingsEvaluation = { settings: {}, settingTaints: new Map() }, + inherited: BuildSettingsEvaluation = { + settings: {}, + settingTaints: new Map(), + globalTaints: [], + globalTaintOverrides: new Set(), + }, visited: Set = new Set(), depth = 0, optional = false, @@ -333,6 +356,13 @@ function addSettingTaint(settingTaints: Map, key: string, tain settingTaints.set(key, unique([...(settingTaints.get(key) ?? []), taint])); } +function settingTaintsFor(evaluation: BuildSettingsEvaluation, key: string): string[] { + return unique([ + ...(evaluation.settingTaints.get(key) ?? []), + ...(evaluation.globalTaintOverrides.has(key) ? [] : evaluation.globalTaints), + ]); +} + function parseInlineBuildSettingKey( rawKey: string, ): @@ -414,7 +444,7 @@ function resolveSetting( ): IOSValueResolution { const settings = evaluation.settings; const initial = settings[key]; - const taints = evaluation.settingTaints.get(key) ?? []; + const taints = settingTaintsFor(evaluation, key); if ((initial == null || initial.trim() === "") && taints.length === 0) { return { state: "missing", evidence: [evidence] }; } @@ -530,6 +560,8 @@ async function settingsForConfiguration( inherited: BuildSettingsEvaluation = { settings: {}, settingTaints: new Map(), + globalTaints: [], + globalTaintOverrides: new Set(), }, ): Promise { let evaluation = cloneEvaluation(inherited); @@ -597,10 +629,19 @@ async function settingsForConfiguration( export interface InspectedTargetConfiguration { model: IOSBuildConfiguration; - settings: Record; + entitlementContexts: EntitlementBuildContext[]; isIOS: boolean; } +export interface EntitlementBuildContext { + label: string; + settings: Record; + settingTaints: Map; + globalTaints: string[]; + globalTaintOverrides: Set; + builtins: Record; +} + interface EvaluatedBuildContext { context: BuildContext; evaluation: BuildSettingsEvaluation; @@ -679,7 +720,7 @@ function missingConfiguration( entitlementsPath: missing, deploymentTarget: missing, }, - settings: {}, + entitlementContexts: [], // The product type identifies this as an application target, but the // dangling configuration does not contain enough evidence to exclude iOS. isIOS: true, @@ -746,6 +787,8 @@ export async function inspectTargetBuildConfigurations(options: { const inherited: BuildSettingsEvaluation = { settings: {}, settingTaints: new Map(), + globalTaints: [], + globalTaintOverrides: new Set(), }; const projectSettings = await settingsForConfiguration( root, @@ -908,7 +951,14 @@ export async function inspectTargetBuildConfigurations(options: { inspected.push({ model, - settings: deviceContext.evaluation.settings, + entitlementContexts: activeContexts.map(({ context, evaluation, builtins }) => ({ + label: context.label, + settings: { ...evaluation.settings }, + settingTaints: cloneSettingTaints(evaluation.settingTaints), + globalTaints: [...evaluation.globalTaints], + globalTaintOverrides: new Set(evaluation.globalTaintOverrides), + builtins: { ...builtins }, + })), isIOS: !explicitlyNonIOS, }); } diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index a7afda995..624ae1d55 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -15,6 +15,33 @@ async function fixture(options: Parameters[1] = {}): Pr return root; } +async function setAssociatedDomainTemplate(root: string, template: string): Promise { + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await Bun.file(entitlementsPath).text(); + await Bun.write( + entitlementsPath, + entitlements.replace("webcredentials:clerk.example.test", template), + ); +} + +async function addTargetBuildSettings( + root: string, + settings: Array<[key: string, value: string]>, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + const serialized = settings + .map(([key, value]) => `"${key.replaceAll('"', '\\"')}" = "${value.replaceAll('"', '\\"')}";`) + .join(" "); + await Bun.write( + projectPath, + project.replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + `${serialized} SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";`, + ), + ); +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -150,6 +177,135 @@ describe("inspectIOSProject", () => { }); }); + test("resolves matching associated-domain variables across device and simulator contexts", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*]", "clerk.example.test"], + ]); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + unresolvedAssociatedDomains: [], + }); + }); + + test("leaves associated-domain variables unresolved when device and simulator differ", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*]", "simulator.example.test"], + ]); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: [], + unresolvedAssociatedDomains: ["webcredentials:$(ASSOCIATED_DOMAIN_HOST)"], + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("associated-domain values"), + }), + ); + }); + + test("leaves associated-domain variables unresolved when x86_64 simulator differs", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*][arch=arm64]", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*][arch=x86_64]", "intel.example.test"], + ]); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: [], + unresolvedAssociatedDomains: ["webcredentials:$(ASSOCIATED_DOMAIN_HOST)"], + }); + }); + + test("ignores simulator-only associated-domain values for device-only targets", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphonesimulator*]", "simulator.example.test"], + ]); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project.replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + "SUPPORTED_PLATFORMS = iphoneos;", + ), + ); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + unresolvedAssociatedDomains: [], + }); + }); + + test("propagates referenced build-setting taints into associated-domain expansion", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["DOMAIN_WRAPPER", "$(ASSOCIATED_DOMAIN_HOST)"], + ["ASSOCIATED_DOMAIN_HOST[variant=unsupported]", "unknown.example.test"], + ]); + await setAssociatedDomainTemplate(root, "webcredentials:$(DOMAIN_WRAPPER)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: [], + unresolvedAssociatedDomains: ["webcredentials:$(DOMAIN_WRAPPER)"], + }); + }); + + test("fails associated-domain expansion closed after an incomplete xcconfig include", async () => { + const root = await fixture({ complete: true, xcconfig: true }); + const xcconfigPath = join(root, "Config", "Target.xcconfig"); + const xcconfig = await Bun.file(xcconfigPath).text(); + await Bun.write( + xcconfigPath, + `${xcconfig}ASSOCIATED_DOMAIN_HOST = clerk.example.test\n#include "Missing.xcconfig"\n`, + ); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: [], + unresolvedAssociatedDomains: ["webcredentials:$(ASSOCIATED_DOMAIN_HOST)"], + }); + }); + + test("keeps literal associated domains resolved when build-setting inputs are incomplete", async () => { + const root = await fixture({ complete: true, xcconfig: true }); + const xcconfigPath = join(root, "Config", "Target.xcconfig"); + const xcconfig = await Bun.file(xcconfigPath).text(); + await Bun.write(xcconfigPath, `${xcconfig}#include "Missing.xcconfig"\n`); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + unresolvedAssociatedDomains: [], + }); + }); + test("does not synthesize workspace markup across XML comments", async () => { const root = await fixture({ workspace: true }); const workspace = join(root, "MyApp.xcworkspace"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 14869e5ce..6febf4973 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -7,6 +7,7 @@ import { addBuildSettingConflictDiagnostics, inspectTargetBuildConfigurations, resolveEntitlementsAbsolutePath, + type EntitlementBuildContext, } from "./build-settings.ts"; import { discoverIOSContainers, @@ -694,7 +695,7 @@ async function attachEntitlements( root: string, projectPath: string, configurations: IOSBuildConfiguration[], - settingsByConfiguration: Map>, + contextsByConfiguration: Map, diagnostics: IOSDiagnostic[], ): Promise { const cache = new Map(); @@ -737,19 +738,21 @@ async function attachEntitlements( const entitlements = cache.get(absolutePath); if (!entitlements) continue; - const settings = settingsByConfiguration.get(configuration.name) ?? {}; + const contexts = contextsByConfiguration.get(configuration.name) ?? []; const resolvedAssociatedDomains: string[] = []; const unresolvedAssociatedDomains: string[] = []; for (const domain of entitlements.associatedDomains) { - const expanded = expandEntitlementDomain( - domain, - settings, - configuration.bundleIdentifier.state === "resolved" - ? configuration.bundleIdentifier.value - : undefined, - ); - if (expanded) resolvedAssociatedDomains.push(expanded); - else unresolvedAssociatedDomains.push(domain); + const expansions = contexts.map((context) => expandEntitlementDomain(domain, context)); + const resolved = expansions.filter((value): value is string => value != null); + if ( + contexts.length > 0 && + resolved.length === contexts.length && + new Set(resolved).size === 1 + ) { + resolvedAssociatedDomains.push(resolved[0]!); + } else { + unresolvedAssociatedDomains.push(domain); + } } if (unresolvedAssociatedDomains.length > 0) { diagnostics.push({ @@ -781,14 +784,9 @@ async function attachEntitlements( function expandEntitlementDomain( raw: string, - settings: Record, - bundleIdentifier: string | undefined, + context: EntitlementBuildContext, ): string | undefined { const variable = /\$\(([^)]+)\)|\$\{([^}]+)\}/g; - const builtins: Record = { - CFBundleIdentifier: bundleIdentifier, - PRODUCT_BUNDLE_IDENTIFIER: bundleIdentifier, - }; const resolving = new Set(); const expand = (value: string, depth: number): string | undefined => { if (depth > 20) return undefined; @@ -796,18 +794,34 @@ function expandEntitlementDomain( variable.lastIndex = 0; const expanded = value.replace(variable, (_match, parenthesized, braced) => { const name = String(parenthesized ?? braced); - if (name.includes(":") || resolving.has(name)) { + if (name.includes(":")) { + unresolved = true; + return ""; + } + const settingName = + context.settings[name] == null && name === "CFBundleIdentifier" + ? "PRODUCT_BUNDLE_IDENTIFIER" + : name; + if (resolving.has(settingName)) { + unresolved = true; + return ""; + } + const taints = [ + ...(context.settingTaints.get(settingName) ?? []), + ...(context.globalTaintOverrides.has(settingName) ? [] : context.globalTaints), + ]; + if (taints.length > 0) { unresolved = true; return ""; } - const replacement = settings[name] ?? builtins[name]; + const replacement = context.settings[settingName] ?? context.builtins[settingName]; if (replacement == null) { unresolved = true; return ""; } - resolving.add(name); + resolving.add(settingName); const nested = expand(replacement, depth + 1); - resolving.delete(name); + resolving.delete(settingName); if (nested == null) unresolved = true; return nested ?? ""; }); @@ -1337,7 +1351,7 @@ async function parseProject( new Map( targetConfigurations.map((configuration) => [ configuration.model.name, - configuration.settings, + configuration.entitlementContexts, ]), ), diagnostics, From 081d13d86cc0dac71803bbfd4b7cd9290107d95c Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 14:19:17 -0400 Subject: [PATCH 10/45] fix(init): filter inactive Xcode platforms --- .../commands/init/ios/build-settings.test.ts | 22 ++++++++++++++++ .../src/commands/init/ios/build-settings.ts | 14 ++++++++--- .../src/commands/init/ios/inspect.test.ts | 25 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 2866b431a..4e4dedcb5 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -371,6 +371,28 @@ describe("inspectTargetBuildConfigurations", () => { ); }); + test("ignores device differences for simulator-only targets", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + SUPPORTED_PLATFORMS: "iphonesimulator", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Simulator", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]": "com.example.StaleDevice", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Simulator", + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + expect(diagnostics).not.toContainEqual( + expect.objectContaining({ code: "xcode.conflicting-build-setting" }), + ); + }); + test("preserves the textual order of xcconfig assignments and includes", async () => { const includeLast = await inspectFixture({ xcconfig: [ diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index aa98be9c6..05bb578cf 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -861,11 +861,17 @@ export async function inspectTargetBuildConfigurations(options: { ); const supportedPlatforms = supportedPlatformsResolution.state === "resolved" ? supportedPlatformsResolution.value : ""; + const supportedPlatformTokens = new Set( + supportedPlatforms + .toLowerCase() + .split(/\s+/) + .filter((value) => value !== ""), + ); + const hasModeledIOSPlatform = + supportedPlatformTokens.has("iphoneos") || supportedPlatformTokens.has("iphonesimulator"); const activeContexts = - supportedPlatformsResolution.state === "resolved" && - supportedPlatforms.trim() !== "" && - !/\biphonesimulator\b/.test(supportedPlatforms) - ? [deviceContext] + supportedPlatformsResolution.state === "resolved" && hasModeledIOSPlatform + ? evaluatedContexts.filter(({ context }) => supportedPlatformTokens.has(context.sdk)) : evaluatedContexts; const sdkRootResolution = resolveSettingAcrossContexts( "SDKROOT", diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 624ae1d55..5feecf820 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -257,6 +257,31 @@ describe("inspectIOSProject", () => { }); }); + test("ignores device-only associated-domain values for simulator-only targets", async () => { + const root = await fixture({ complete: true }); + await addTargetBuildSettings(root, [ + ["ASSOCIATED_DOMAIN_HOST", "clerk.example.test"], + ["ASSOCIATED_DOMAIN_HOST[sdk=iphoneos*]", "stale-device.example.test"], + ]); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project.replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + "SUPPORTED_PLATFORMS = iphonesimulator;", + ), + ); + await setAssociatedDomainTemplate(root, "webcredentials:$(ASSOCIATED_DOMAIN_HOST)"); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + unresolvedAssociatedDomains: [], + }); + }); + test("propagates referenced build-setting taints into associated-domain expansion", async () => { const root = await fixture({ complete: true }); await addTargetBuildSettings(root, [ From 217d75033cd115ec05297a5f3475a1218ebfdb26 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 14:49:06 -0400 Subject: [PATCH 11/45] fix(init): preserve xcconfig continuations --- .../commands/init/ios/build-settings.test.ts | 67 +++++++++++++++++++ .../src/commands/init/ios/build-settings.ts | 60 +++++++++++++++-- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 4e4dedcb5..86f1b8673 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -352,6 +352,50 @@ describe("inspectTargetBuildConfigurations", () => { ); }); + test("preserves all active platforms from continued xcconfig values", async () => { + const { configurations, diagnostics } = await inspectFixture({ + xcconfig: [ + "SUPPORTED_PLATFORMS = iphonesimulator \\ // device support continues below", + " iphoneos", + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*] = com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*] = com.example.Simulator", + ].join("\n"), + targetBuildSettings: { + SUPPORTED_PLATFORMS: "$(inherited)", + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphoneos/arm64", + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.conflicting-build-setting", + message: expect.stringContaining("iphoneos/arm64=com.example.Device"), + }), + ); + }); + + test("removes only the final backslash from a continued xcconfig value", async () => { + const { configurations } = await inspectFixture({ + xcconfig: ["DEVELOPMENT_TEAM = ABCDE123\\\\", "45"].join("\n"), + targetBuildSettings: { DEVELOPMENT_TEAM: "$(inherited)" }, + }); + + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "ABCDE123\\ 45", + }); + }); + test("ignores simulator architecture differences for device-only targets", async () => { const { configurations, diagnostics } = await inspectFixture({ targetBuildSettings: { @@ -397,6 +441,8 @@ describe("inspectTargetBuildConfigurations", () => { const includeLast = await inspectFixture({ xcconfig: [ "PRODUCT_BUNDLE_IDENTIFIER = com.example.Before", + "HEADER_SEARCH_PATHS = $(SRCROOT)/include \\", + " $(SRCROOT)/include/component", '#include "Included.xcconfig"', ].join("\n"), includedXCConfig: "PRODUCT_BUNDLE_IDENTIFIER = com.example.Included\n", @@ -405,6 +451,8 @@ describe("inspectTargetBuildConfigurations", () => { const assignmentLast = await inspectFixture({ xcconfig: [ '#include "Included.xcconfig"', + "HEADER_SEARCH_PATHS = $(SRCROOT)/include \\", + " $(SRCROOT)/include/component", "PRODUCT_BUNDLE_IDENTIFIER = com.example.After", ].join("\n"), includedXCConfig: "PRODUCT_BUNDLE_IDENTIFIER = com.example.Included\n", @@ -421,6 +469,25 @@ describe("inspectTargetBuildConfigurations", () => { }); }); + test("fails closed when continuation syntax is used for an include", async () => { + const { configurations, diagnostics } = await inspectFixture({ + xcconfig: ["#include \\", ' "Included.xcconfig"'].join("\n"), + includedXCConfig: "PRODUCT_BUNDLE_IDENTIFIER = com.example.Included\n", + targetBuildSettings: { PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)" }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["unsupported xcconfig continuation"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("continuation that could not be evaluated safely"), + }), + ); + }); + test("taints fallback settings when a required xcconfig include is missing", async () => { const { configurations, diagnostics } = await inspectFixture({ xcconfig: [ diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 05bb578cf..ebbb801c9 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -46,7 +46,8 @@ interface BuildSettingsEvaluation { type XCConfigOperation = | { kind: "include"; path: string; optional: boolean } - | { kind: "setting"; key: string; value: string; conditions?: XCConfigCondition[] }; + | { kind: "setting"; key: string; value: string; conditions?: XCConfigCondition[] } + | { kind: "unresolved-continuation" }; const BUILD_CONTEXTS: BuildContext[] = [ { label: "iphoneos/arm64", sdk: "iphoneos", arch: "arm64" }, @@ -154,17 +155,36 @@ function addDiagnosticOnce(diagnostics: IOSDiagnostic[], diagnostic: IOSDiagnost function parseXCConfigOperations(content: string): XCConfigOperation[] { const operations: XCConfigOperation[] = []; // @bacons/xcode intentionally exposes includes and assignments separately. - // Parsing one source line at a time retains their interleaving, which is - // significant because a later include can override an earlier assignment. - for (const line of content.split(/\r?\n/)) { + // Parsing one logical source line at a time retains their interleaving, + // which is significant because a later include can override an earlier + // assignment. Xcode replaces a trailing backslash plus newline with a space, + // including when whitespace or a line comment follows the backslash. + let logicalLine = ""; + let usedContinuation = false; + for (const rawLine of content.split(/\r?\n/)) { + const withoutComment = rawLine.replace(/\/\/.*/, "").trimEnd(); + const continues = withoutComment.endsWith("\\"); + const fragment = (continues ? withoutComment.slice(0, -1) : withoutComment).trim(); + logicalLine = logicalLine ? `${logicalLine} ${fragment}`.trim() : fragment; + usedContinuation ||= continues; + if (continues) continue; + + const line = logicalLine; + logicalLine = ""; const parsed = parseXCConfig(line); const include = parsed.includes[0]?.include; if (include) { + if (usedContinuation) { + operations.push({ kind: "unresolved-continuation" }); + usedContinuation = false; + continue; + } operations.push({ kind: "include", path: include.path, optional: include.optional, }); + usedContinuation = false; continue; } const setting = parsed.buildSettings[0]; @@ -175,6 +195,26 @@ function parseXCConfigOperations(content: string): XCConfigOperation[] { value: setting.value, conditions: setting.conditions, }); + } else if (usedContinuation && line) { + operations.push({ kind: "unresolved-continuation" }); + } + usedContinuation = false; + } + + // Xcode accepts a trailing continuation at EOF and removes the final + // backslash, so parse the accumulated assignment once more. + if (logicalLine) { + const parsed = parseXCConfig(logicalLine); + const setting = parsed.buildSettings[0]; + if (setting) { + operations.push({ + kind: "setting", + key: setting.key, + value: setting.value, + conditions: setting.conditions, + }); + } else { + operations.push({ kind: "unresolved-continuation" }); } } return operations; @@ -300,6 +340,18 @@ async function readXCConfigSettings( let evaluation = cloneEvaluation(inherited); for (const operation of parseXCConfigOperations(content)) { + if (operation.kind === "unresolved-continuation") { + taintInspectedSettings(evaluation, "unsupported xcconfig continuation"); + addDiagnosticOnce(diagnostics, { + code: "xcode.unresolved-build-setting", + severity: "warning", + message: `${relativeIOSPath(root, path)} has an xcconfig continuation that could not be evaluated safely.`, + remedy: + "Keep continued build-setting values on consecutive lines using a trailing backslash.", + evidence: [{ path: relativeIOSPath(root, path), keyPath: "continuation" }], + }); + continue; + } if (operation.kind === "setting") { if (!conditionsMatch(operation.conditions, configuration, context)) continue; applyEvaluatedSetting(evaluation, operation.key, operation.value, operation.conditions); From 4dfa95504268ab15bf6deb077666e33d2e444a73 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 14:57:41 -0400 Subject: [PATCH 12/45] fix(init): handle xcconfig continuation variants --- .../commands/init/ios/build-settings.test.ts | 52 +++++++++++++++++++ .../src/commands/init/ios/build-settings.ts | 10 ++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 86f1b8673..0609f0864 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -384,6 +384,58 @@ describe("inspectTargetBuildConfigurations", () => { ); }); + test("accepts a semicolon after an xcconfig continuation marker", async () => { + const { configurations } = await inspectFixture({ + xcconfig: [ + "SUPPORTED_PLATFORMS = iphonesimulator \\;", + " iphoneos", + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*] = com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*] = com.example.Simulator", + ].join("\n"), + targetBuildSettings: { + SUPPORTED_PLATFORMS: "$(inherited)", + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphoneos/arm64", + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + }); + + test("preserves continued xcconfig values with CR-only line endings", async () => { + const { configurations } = await inspectFixture({ + xcconfig: [ + "SUPPORTED_PLATFORMS = iphonesimulator \\", + " iphoneos", + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Native", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*] = com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*] = com.example.Simulator", + ].join("\r"), + targetBuildSettings: { + SUPPORTED_PLATFORMS: "$(inherited)", + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited)", + }, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphoneos/arm64", + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + }); + test("removes only the final backslash from a continued xcconfig value", async () => { const { configurations } = await inspectFixture({ xcconfig: ["DEVELOPMENT_TEAM = ABCDE123\\\\", "45"].join("\n"), diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index ebbb801c9..351bd7ce5 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -161,10 +161,14 @@ function parseXCConfigOperations(content: string): XCConfigOperation[] { // including when whitespace or a line comment follows the backslash. let logicalLine = ""; let usedContinuation = false; - for (const rawLine of content.split(/\r?\n/)) { + for (const rawLine of content.split(/\r\n|\r|\n/)) { const withoutComment = rawLine.replace(/\/\/.*/, "").trimEnd(); - const continues = withoutComment.endsWith("\\"); - const fragment = (continues ? withoutComment.slice(0, -1) : withoutComment).trim(); + // Xcode also accepts an optional assignment terminator after a + // continuation marker (`VALUE = first \;`). Remove that terminator before + // checking for and consuming the final continuation backslash. + const continuationCandidate = withoutComment.replace(/\\\s*;$/, "\\").trimEnd(); + const continues = continuationCandidate.endsWith("\\"); + const fragment = (continues ? continuationCandidate.slice(0, -1) : withoutComment).trim(); logicalLine = logicalLine ? `${logicalLine} ${fragment}`.trim() : fragment; usedContinuation ||= continues; if (continues) continue; From 118173fd2a938fd5c3af2721554070027c907aa2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 16:48:20 -0400 Subject: [PATCH 13/45] fix(init): bind file mutations to prepared roots --- .../init/ios/file-transaction.test.ts | 70 +++++- .../src/commands/init/ios/file-transaction.ts | 222 ++++++++++++++---- 2 files changed, 249 insertions(+), 43 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts index 090d049e9..4811fbac0 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -19,6 +19,7 @@ import { applyIOSFileTransaction, hashIOSFileBytes, IOSFileTransactionError, + prepareIOSFileMutationBoundary, type IOSCreateFileMutation, type IOSExistingFileMutation, } from "./file-transaction.ts"; @@ -31,12 +32,19 @@ async function temporaryRoot(): Promise { return root; } -async function mutation(path: string, candidate: string): Promise { +async function mutation( + path: string, + candidate: string, + root = dirname(path), +): Promise { const originalBytes = new Uint8Array(await readFile(path)); const candidateBytes = new TextEncoder().encode(candidate); const info = await lstat(path); + const boundary = await prepareIOSFileMutationBoundary(root, path); + if (!boundary) throw new Error("expected a safe file mutation boundary"); return { path, + boundary, originalBytes, originalHash: hashIOSFileBytes(originalBytes), candidateBytes, @@ -49,13 +57,15 @@ async function createMutation( path: string, candidate: string, mode = 0o644, + root = dirname(path), ): Promise { const candidateBytes = new TextEncoder().encode(candidate); - const parent = await lstat(dirname(path)); + const boundary = await prepareIOSFileMutationBoundary(root, path); + if (!boundary) throw new Error("expected a safe file mutation boundary"); return { kind: "create", path, - expectedParentIdentity: { device: parent.dev, inode: parent.ino }, + boundary, candidateBytes, candidateHash: hashIOSFileBytes(candidateBytes), mode, @@ -114,6 +124,60 @@ afterEach(async () => { }); describe("iOS existing-file transaction", () => { + test("preserves an out-of-root file when its prepared parent is replaced before claim", async () => { + const root = await temporaryRoot(); + const projectRoot = join(root, "project"); + const preparedParent = join(projectRoot, "Sources"); + const outsideRoot = join(root, "outside"); + const displacedParent = join(outsideRoot, "Sources"); + const path = join(preparedParent, "App.swift"); + await mkdir(preparedParent, { recursive: true }); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n", projectRoot); + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { + beforeExistingDestinationClaim: async () => { + await rename(preparedParent, displacedParent); + await symlink(displacedParent, preparedParent, "dir"); + }, + }); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(join(displacedParent, "App.swift"), "utf8")).toBe("original source\n"); + expect(await readFile(path, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(displacedParent); + }); + + test("rejects an ancestor symlink even when the prepared parent inode still matches", async () => { + const root = await temporaryRoot(); + const projectRoot = join(root, "project"); + const preparedAncestor = join(projectRoot, "App"); + const preparedParent = join(preparedAncestor, "Sources"); + const outsideRoot = join(root, "outside"); + const displacedAncestor = join(outsideRoot, "App"); + const path = join(preparedParent, "App.swift"); + await mkdir(preparedParent, { recursive: true }); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n", projectRoot); + const preparedParentIdentity = await lstat(preparedParent); + + await rename(preparedAncestor, displacedAncestor); + await symlink(displacedAncestor, preparedAncestor, "dir"); + const redirectedParentIdentity = await lstat(preparedParent); + expect(redirectedParentIdentity.dev).toBe(preparedParentIdentity.dev); + expect(redirectedParentIdentity.ino).toBe(preparedParentIdentity.ino); + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true]); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(join(displacedAncestor, "Sources", "App.swift"), "utf8")).toBe( + "original source\n", + ); + await expectNoTemporaryFiles(join(displacedAncestor, "Sources")); + }); + test("stages and commits every file sequentially while preserving modes", async () => { const root = await temporaryRoot(); const firstPath = join(root, "project.pbxproj"); diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts index 37a6a2a93..4a4693951 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -1,6 +1,20 @@ -import { link, lstat, open, readFile, rename, rm } from "node:fs/promises"; +import { link, lstat, open, readFile, realpath, rename, rm } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import { basename, dirname, isAbsolute, resolve } from "node:path"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; + +/** + * The exact root and parent directory authorized while preparing a mutation. + * + * @internal Paths are canonical filesystem evidence and must not be serialized + * into plans or included in CLI output or telemetry. + */ +export interface IOSFileMutationBoundary { + rootPath: string; + realRootPath: string; + rootIdentity: { device: number; inode: number }; + realParentPath: string; + parentIdentity: { device: number; inode: number }; +} /** * An already-inspected existing file and its validated replacement bytes. @@ -10,6 +24,7 @@ import { basename, dirname, isAbsolute, resolve } from "node:path"; */ export interface IOSExistingFileMutation { path: string; + boundary: IOSFileMutationBoundary; originalBytes: Uint8Array; originalHash: string; candidateBytes: Uint8Array; @@ -26,8 +41,7 @@ export interface IOSExistingFileMutation { export interface IOSCreateFileMutation { kind: "create"; path: string; - /** Exact parent directory inspected while preparing this create. */ - expectedParentIdentity: { device: number; inode: number }; + boundary: IOSFileMutationBoundary; candidateBytes: Uint8Array; candidateHash: string; mode: number; @@ -203,18 +217,86 @@ function sameFile(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino; } -async function createParentStillMatches(mutation: IOSCreateFileMutation): Promise { - const current = await readDirectoryIdentity(dirname(mutation.path)); +function directoryIdentitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return left.device === right.device && left.inode === right.inode; +} + +function pathIsWithin(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function readCurrentMutationBoundary( + rootPath: string, + parentPath: string, +): Promise | undefined> { + try { + const realRootPath = await realpath(rootPath); + const realParentPath = await realpath(parentPath); + if (!pathIsWithin(realRootPath, realParentPath)) return undefined; + + const rootIdentity = await readDirectoryIdentity(realRootPath); + const parentIdentity = await readDirectoryIdentity(realParentPath); + if (!rootIdentity || !parentIdentity) return undefined; + + const realRootAfterRead = await realpath(rootPath); + const realParentAfterRead = await realpath(parentPath); + const rootIdentityAfterRead = await readDirectoryIdentity(realRootAfterRead); + const parentIdentityAfterRead = await readDirectoryIdentity(realParentAfterRead); + if ( + realRootAfterRead !== realRootPath || + realParentAfterRead !== realParentPath || + !rootIdentityAfterRead || + !parentIdentityAfterRead || + !directoryIdentitiesMatch(rootIdentityAfterRead, rootIdentity) || + !directoryIdentitiesMatch(parentIdentityAfterRead, parentIdentity) + ) { + return undefined; + } + + return { realRootPath, rootIdentity, realParentPath, parentIdentity }; + } catch { + return undefined; + } +} + +/** + * Captures the root and parent directory authorized for a prepared mutation. + * Both paths are resolved twice around the identity reads so a moving or + * replaced directory is rejected instead of being recorded inconsistently. + * + * @internal The returned evidence belongs only in hidden mutation state. + */ +export async function prepareIOSFileMutationBoundary( + rootInput: string, + mutationPathInput: string, +): Promise { + const rootPath = resolve(rootInput); + const mutationPath = resolve(mutationPathInput); + if (!pathIsWithin(rootPath, mutationPath)) return undefined; + + const current = await readCurrentMutationBoundary(rootPath, dirname(mutationPath)); + return current ? { rootPath, ...current } : undefined; +} + +async function mutationBoundaryStillMatches(mutation: IOSFileMutation): Promise { + const { boundary } = mutation; + if (!pathIsWithin(boundary.rootPath, mutation.path)) return false; + const current = await readCurrentMutationBoundary(boundary.rootPath, dirname(mutation.path)); return ( current !== undefined && - current.device === mutation.expectedParentIdentity.device && - current.inode === mutation.expectedParentIdentity.inode + current.realRootPath === boundary.realRootPath && + current.realParentPath === boundary.realParentPath && + directoryIdentitiesMatch(current.rootIdentity, boundary.rootIdentity) && + directoryIdentitiesMatch(current.parentIdentity, boundary.parentIdentity) ); } -async function createParentsStillMatch(mutations: readonly IOSFileMutation[]): Promise { +async function mutationBoundariesStillMatch( + mutations: readonly IOSFileMutation[], +): Promise { const matches = await Promise.all( - mutations.filter(isCreateMutation).map(async (mutation) => createParentStillMatches(mutation)), + mutations.map(async (mutation) => mutationBoundaryStillMatches(mutation)), ); return matches.every(Boolean); } @@ -398,6 +480,26 @@ function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileM return "kind" in mutation && mutation.kind === "create"; } +function mutationBoundaryIsValid(mutation: IOSFileMutation): boolean { + const { boundary } = mutation; + return ( + boundary != null && + isAbsolute(boundary.rootPath) && + isAbsolute(boundary.realRootPath) && + isAbsolute(boundary.realParentPath) && + pathIsWithin(boundary.rootPath, mutation.path) && + pathIsWithin(boundary.realRootPath, boundary.realParentPath) && + Number.isSafeInteger(boundary.rootIdentity?.device) && + boundary.rootIdentity.device >= 0 && + Number.isSafeInteger(boundary.rootIdentity.inode) && + boundary.rootIdentity.inode >= 0 && + Number.isSafeInteger(boundary.parentIdentity?.device) && + boundary.parentIdentity.device >= 0 && + Number.isSafeInteger(boundary.parentIdentity.inode) && + boundary.parentIdentity.inode >= 0 + ); +} + function validateMutations(mutations: readonly IOSFileMutation[]): void { if (mutations.length === 0) { throw transactionError( @@ -414,12 +516,8 @@ function validateMutations(mutations: readonly IOSFileMutation[]): void { !Number.isInteger(mutation.mode) || mutation.mode < 0 || mutation.mode > 0o7777 || + !mutationBoundaryIsValid(mutation) || hashIOSFileBytes(mutation.candidateBytes) !== mutation.candidateHash || - (isCreateMutation(mutation) && - (!Number.isSafeInteger(mutation.expectedParentIdentity?.device) || - mutation.expectedParentIdentity.device < 0 || - !Number.isSafeInteger(mutation.expectedParentIdentity.inode) || - mutation.expectedParentIdentity.inode < 0)) || (!isCreateMutation(mutation) && hashIOSFileBytes(mutation.originalBytes) !== mutation.originalHash) ) { @@ -438,11 +536,18 @@ function snapshotMutations(mutations: readonly IOSFileMutation[]): IOSFileMutati if (!mutation.path || !isAbsolute(mutation.path)) { throw new Error("mutation path must be absolute"); } + const boundary: IOSFileMutationBoundary = { + rootPath: resolve(mutation.boundary.rootPath), + realRootPath: resolve(mutation.boundary.realRootPath), + rootIdentity: { ...mutation.boundary.rootIdentity }, + realParentPath: resolve(mutation.boundary.realParentPath), + parentIdentity: { ...mutation.boundary.parentIdentity }, + }; if (isCreateMutation(mutation)) { return { kind: "create", path: resolve(mutation.path), - expectedParentIdentity: { ...mutation.expectedParentIdentity }, + boundary, candidateBytes: new Uint8Array(mutation.candidateBytes), candidateHash: mutation.candidateHash, mode: mutation.mode, @@ -450,6 +555,7 @@ function snapshotMutations(mutations: readonly IOSFileMutation[]): IOSFileMutati } return { path: resolve(mutation.path), + boundary, originalBytes: new Uint8Array(mutation.originalBytes), originalHash: mutation.originalHash, candidateBytes: new Uint8Array(mutation.candidateBytes), @@ -478,7 +584,7 @@ async function stageBytes( let created = false; let openedIdentity: FileIdentity | undefined; try { - if (isCreateMutation(mutation) && !(await createParentStillMatches(mutation))) { + if (!(await mutationBoundaryStillMatches(mutation))) { throw new IOSFileTransactionStaleError(); } const temporary = await open(temporaryPath, "wx", mutation.mode); @@ -504,7 +610,7 @@ async function stageBytes( if (!stagedIdentity || stagedIdentity.mode !== mutation.mode) { throw new Error("staged file identity did not match its prepared mode"); } - if (isCreateMutation(mutation) && !(await createParentStillMatches(mutation))) { + if (!(await mutationBoundaryStillMatches(mutation))) { throw new IOSFileTransactionStaleError(); } return { mutation, temporaryPath, temporaryPresent: true, stagedIdentity }; @@ -590,8 +696,11 @@ async function captureInitialExistingFileIdentities( const states = await Promise.all( mutations.map(async (mutation) => { if (isCreateMutation(mutation)) { - return (await createParentStillMatches(mutation)) && (await pathIsAbsent(mutation.path)); + return ( + (await mutationBoundaryStillMatches(mutation)) && (await pathIsAbsent(mutation.path)) + ); } + if (!(await mutationBoundaryStillMatches(mutation))) return false; const identity = await readRegularFileIdentity(mutation.path); if ( !identity || @@ -612,10 +721,11 @@ async function originalStateStillMatches( initialIdentities: ReadonlyMap, ): Promise { if (isCreateMutation(mutation)) { - return (await createParentStillMatches(mutation)) && (await pathIsAbsent(mutation.path)); + return (await mutationBoundaryStillMatches(mutation)) && (await pathIsAbsent(mutation.path)); } const identity = initialIdentities.get(mutation.path); return ( + (await mutationBoundaryStillMatches(mutation)) && identity !== undefined && fileMatchesIdentityAndHash(mutation.path, identity, mutation.originalHash) ); @@ -632,7 +742,7 @@ async function originalStatesStillMatch( } async function createdCandidateIsUntouched(item: StagedMutation): Promise { - if (!isCreateMutation(item.mutation) || !(await createParentStillMatches(item.mutation))) { + if (!isCreateMutation(item.mutation) || !(await mutationBoundaryStillMatches(item.mutation))) { return false; } try { @@ -655,7 +765,7 @@ async function createdCandidateIsUntouched(item: StagedMutation): Promise { if (isCreateMutation(item.mutation)) return createdCandidateIsUntouched(item); - if (!item.committedIdentity) return false; + if (!item.committedIdentity || !(await mutationBoundaryStillMatches(item.mutation))) return false; if ( !(await fileMatchesIdentityAndHash( item.mutation.path, @@ -691,6 +801,7 @@ async function claimedOriginalIsUntouched(item: StagedMutation): Promise item.mutation)))) { + if (!(await mutationBoundariesStillMatch(committed.map((item) => item.mutation)))) { cleanupError ??= transactionError( "rollback-failed", - "An iOS create destination changed while rollback was being finalized; newer filesystem state was preserved.", + "An iOS mutation boundary changed while rollback was being finalized; newer filesystem state was preserved.", ); } if (rollbackError || cleanupError) { @@ -1054,7 +1188,7 @@ export async function applyIOSFileTransaction( let stale = false; let commitError: unknown; for (const item of staged) { - if (!(await createParentsStillMatch(prepared))) { + if (!(await mutationBoundariesStillMatch(prepared))) { stale = true; break; } @@ -1078,6 +1212,10 @@ export async function applyIOSFileTransaction( // absent. A concurrent writer therefore wins with EEXIST instead of // being overwritten. await hooks.beforeExistingDestinationClaim?.(item.mutation.path); + if (!(await mutationBoundariesStillMatch(prepared))) { + stale = true; + break; + } const claimResult = await claimDestination( item.mutation.path, expectedIdentity, @@ -1090,7 +1228,7 @@ export async function applyIOSFileTransaction( item.claimedOriginal = claimResult.claim; await hooks.afterExistingDestinationClaim?.(item.mutation.path, item.claimedOriginal.path); if ( - !(await createParentsStillMatch(prepared)) || + !(await mutationBoundariesStillMatch(prepared)) || !(await claimedOriginalIsUntouched(item)) || !(await fileMatchesIdentityAndHash( item.temporaryPath, @@ -1105,7 +1243,7 @@ export async function applyIOSFileTransaction( item.claimedOriginal.path, ); if ( - !(await createParentsStillMatch(prepared)) || + !(await mutationBoundariesStillMatch(prepared)) || !(await claimedOriginalIsUntouched(item)) ) { throw new IOSFileTransactionStaleError(); @@ -1138,7 +1276,7 @@ export async function applyIOSFileTransaction( item.claimedOriginal.path, ); } - if (!(await createParentsStillMatch(prepared))) { + if (!(await mutationBoundariesStillMatch(prepared))) { throw new IOSFileTransactionStaleError(); } const committedIdentity = await readRegularFileIdentity(item.mutation.path); @@ -1147,12 +1285,16 @@ export async function applyIOSFileTransaction( } item.committedIdentity = committedIdentity; await syncDirectory(dirname(item.mutation.path)); - if (!(await createParentsStillMatch(prepared))) { + if (!(await mutationBoundariesStillMatch(prepared))) { throw new IOSFileTransactionStaleError(); } } catch (error) { let effectiveError = error; - if (!committedThisItem && item.claimedOriginal?.present) { + if ( + !committedThisItem && + item.claimedOriginal?.present && + (await mutationBoundaryStillMatches(item.mutation)) + ) { try { await restoreClaimWithoutClobber(item.claimedOriginal, item.mutation.path); } catch (cleanupError) { @@ -1184,19 +1326,19 @@ export async function applyIOSFileTransaction( ); } - const parentsMatchedBeforePostvalidation = await createParentsStillMatch(prepared); + const boundariesMatchedBeforePostvalidation = await mutationBoundariesStillMatch(prepared); const candidatesMatchedBeforePostvalidation = await committedCandidatesAreUntouched(committed); const originalsMatchedBeforePostvalidation = await claimedOriginalsAreUntouched(committed); const postconditionsValid = await postconditionsAreValid(postconditions); - const parentsMatchedAfterPostvalidation = await createParentsStillMatch(prepared); + const boundariesMatchedAfterPostvalidation = await mutationBoundariesStillMatch(prepared); const candidatesMatchedAfterPostvalidation = await committedCandidatesAreUntouched(committed); const originalsMatchedAfterPostvalidation = await claimedOriginalsAreUntouched(committed); if ( - parentsMatchedBeforePostvalidation && + boundariesMatchedBeforePostvalidation && candidatesMatchedBeforePostvalidation && originalsMatchedBeforePostvalidation && postconditionsValid && - parentsMatchedAfterPostvalidation && + boundariesMatchedAfterPostvalidation && candidatesMatchedAfterPostvalidation && originalsMatchedAfterPostvalidation ) { @@ -1213,7 +1355,7 @@ export async function applyIOSFileTransaction( } await cleanupStaged(staged); if ( - (await createParentsStillMatch(prepared)) && + (await mutationBoundariesStillMatch(prepared)) && (await committedCandidatesAreUntouched(committed)) ) { return { status: "applied" }; @@ -1223,9 +1365,9 @@ export async function applyIOSFileTransaction( } await rollbackAfterFailure(committed, staged, hooks); - return parentsMatchedBeforePostvalidation && + return boundariesMatchedBeforePostvalidation && candidatesMatchedBeforePostvalidation && - parentsMatchedAfterPostvalidation && + boundariesMatchedAfterPostvalidation && candidatesMatchedAfterPostvalidation && originalsMatchedBeforePostvalidation && originalsMatchedAfterPostvalidation From 9edcf6ae1498dfc9c94ff35de4bc50caf173a4f8 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 16:55:32 -0400 Subject: [PATCH 14/45] fix(init): restore claims after boundary changes --- .../init/ios/file-transaction.test.ts | 70 +++++++++++++++++++ .../src/commands/init/ios/file-transaction.ts | 11 +-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts index 4811fbac0..a384c95db 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -149,6 +149,76 @@ describe("iOS existing-file transaction", () => { await expectNoTemporaryFiles(displacedParent); }); + test.each([ + ["after destination claim", "afterExistingDestinationClaim"], + ["immediately before destination install", "beforeExistingDestinationInstall"], + ] as const)("restores a claimed original when its parent moves %s", async (_label, hook) => { + const root = await temporaryRoot(); + const projectRoot = join(root, "project"); + const preparedParent = join(projectRoot, "Sources"); + const outsideRoot = join(root, "outside"); + const displacedParent = join(outsideRoot, "Sources"); + const path = join(preparedParent, "App.swift"); + await mkdir(preparedParent, { recursive: true }); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + await chmod(path, 0o640); + const originalIdentity = await lstat(path); + const prepared = await mutation(path, "candidate source\n", projectRoot); + const moveParent = async () => { + await rename(preparedParent, displacedParent); + await symlink(displacedParent, preparedParent, "dir"); + }; + + const result = await applyIOSExistingFileTransaction( + [prepared], + [async () => true], + hook === "afterExistingDestinationClaim" + ? { afterExistingDestinationClaim: moveParent } + : { beforeExistingDestinationInstall: moveParent }, + ); + + expect(result).toEqual({ status: "stale" }); + expect(await readFile(path, "utf8")).toBe("original source\n"); + expect(await readFile(join(displacedParent, "App.swift"), "utf8")).toBe("original source\n"); + const restoredIdentity = await lstat(path); + expect(restoredIdentity.ino).toBe(originalIdentity.ino); + expect(restoredIdentity.mode & 0o7777).toBe(0o640); + await expectNoTemporaryFiles(displacedParent); + }); + + test("fails explicitly rather than clobbering a newer file when a claimed original cannot be restored", async () => { + const root = await temporaryRoot(); + const projectRoot = join(root, "project"); + const preparedParent = join(projectRoot, "Sources"); + const outsideRoot = join(root, "outside"); + const displacedParent = join(outsideRoot, "Sources"); + const path = join(preparedParent, "App.swift"); + await mkdir(preparedParent, { recursive: true }); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n", projectRoot); + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], [async () => true], { + afterExistingDestinationClaim: async () => { + await rename(preparedParent, displacedParent); + await symlink(displacedParent, preparedParent, "dir"); + await writeFile(path, "newer editor source\n"); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("commit-failed"); + expect(errorText(caught)).toContain("a claimed original could not be restored"); + expect(await readFile(path, "utf8")).toBe("newer editor source\n"); + await expectRecoverableClaimedOriginals(displacedParent, ["original source\n"]); + }); + test("rejects an ancestor symlink even when the prepared parent inode still matches", async () => { const root = await temporaryRoot(); const projectRoot = join(root, "project"); diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts index 4a4693951..2629b9b74 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -1290,11 +1290,12 @@ export async function applyIOSFileTransaction( } } catch (error) { let effectiveError = error; - if ( - !committedThisItem && - item.claimedOriginal?.present && - (await mutationBoundaryStillMatches(item.mutation)) - ) { + // A successful claim removed the public destination. Recovery must run + // even if its parent moved afterward: restoration uses an exclusive + // link, so a newer destination wins without being overwritten. If the + // claimed inode can no longer be restored safely, escalate explicitly + // instead of returning stale with the original stranded under its claim. + if (!committedThisItem && item.claimedOriginal?.present) { try { await restoreClaimWithoutClobber(item.claimedOriginal, item.mutation.path); } catch (cleanupError) { From f7101ea7672a194ad35e7ffa02f933cb9d10e9e6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 18:51:58 -0400 Subject: [PATCH 15/45] fix(init): select runtime keys from proven wiring --- .../src/commands/init/ios/inspect.test.ts | 181 +++++++++++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 51 ++++- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 5feecf820..3aac2db62 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -42,6 +42,17 @@ async function addTargetBuildSettings( ); } +async function writeSelectedTargetRunSchemeKey(root: string, value: string): Promise { + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemePath = join(schemeDirectory, "MyApp.xcscheme"); + await Bun.write( + schemePath, + ``, + ); + return schemePath; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -146,10 +157,7 @@ describe("inspectIOSProject", () => { }); expect(target?.runtimeKeySinks).toEqual([]); expect(inspection.localPublishableKey).toEqual({ - found: true, - source: ".env", - frontendApiHost: "clerk.example.test", - instanceType: "development", + found: false, conflict: false, candidateSources: [".env"], invalidSources: [], @@ -478,8 +486,11 @@ describe("inspectIOSProject", () => { }); test("treats a direct @main literal as the selected target's runtime key without exposing it", async () => { - const root = await fixture({ includeKey: false }); + const root = await fixture({ includeKey: false, localSecrets: true }); const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + const schemeKey = `pk_live_${Buffer.from("scheme.clerk.example$").toString("base64")}`; + const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; + await writeSelectedTargetRunSchemeKey(root, schemeKey); await Bun.write( join(root, "MyApp", "MyAppApp.swift"), `import ClerkKit @@ -509,10 +520,54 @@ struct MyApp: App { source: "MyApp/MyAppApp.swift", frontendApiHost: "inline.clerk.example", instanceType: "development", - candidateSources: ["MyApp/MyAppApp.swift"], + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + "MyApp/MyAppApp.swift", + ], invalidSources: [], }); expect(JSON.stringify(inspection)).not.toContain(publishableKey); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); + }); + + test("does not fall through from an invalid app-init literal to other key sources", async () => { + const root = await fixture({ includeKey: false, localSecrets: true }); + const invalidInlineKey = "pk_test_inline-secret-must-not-leak"; + const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; + await writeSelectedTargetRunSchemeKey(root, schemeKey); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${invalidInlineKey}") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + source: "MyApp/MyAppApp.swift", + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + "MyApp/MyAppApp.swift", + ], + invalidSources: ["MyApp/MyAppApp.swift"], + }); + expect(JSON.stringify(inspection)).not.toContain(invalidInlineKey); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); test("reads an enabled publishable key from the selected target's Run scheme", async () => { @@ -536,6 +591,120 @@ struct MyApp: App { expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); + test("uses the LocalSecrets key proven by app-init wiring instead of a different scheme key", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; + const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; + await writeSelectedTargetRunSchemeKey(root, schemeKey); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: true, + source: "MyApp/LocalSecrets.plist", + frontendApiHost: "native.clerk.example", + instanceType: "production", + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + ], + invalidSources: [], + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); + }); + + test("does not fall through from a malformed LocalSecrets key proven by app-init wiring", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; + const malformedLocalSecretsKey = "pk_live_local-secret-must-not-leak"; + await writeSelectedTargetRunSchemeKey(root, schemeKey); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${malformedLocalSecretsKey}`, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + source: "MyApp/LocalSecrets.plist", + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + ], + invalidSources: ["MyApp/LocalSecrets.plist"], + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + expect(JSON.stringify(inspection)).not.toContain(malformedLocalSecretsKey); + }); + + test("does not fall through from an empty LocalSecrets handoff to a stale scheme key", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const schemeKey = `pk_test_${Buffer.from("stale-scheme.clerk.example$").toString("base64")}`; + await writeSelectedTargetRunSchemeKey(root, schemeKey); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + '', + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: false, + conflict: false, + candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], + invalidSources: [], + }); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "clerk.invalid-publishable-key", + ), + ).toBe(false); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + }); + + test("uses the selected target's scheme when app-init reads ProcessInfo", async () => { + const root = await fixture({ includeKey: false, localSecrets: true }); + const schemeKey = `pk_test_${Buffer.from("scheme-runtime.clerk.example$").toString("base64")}`; + const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; + await writeSelectedTargetRunSchemeKey(root, schemeKey); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + found: true, + source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + frontendApiHost: "scheme-runtime.clerk.example", + instanceType: "development", + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + "MyApp/LocalSecrets.plist", + ], + invalidSources: [], + }); + expect(JSON.stringify(inspection)).not.toContain(schemeKey); + expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); + }); + test("does not synthesize Run scheme markup across XML comments", async () => { const root = await fixture({ includeKey: false }); const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 6febf4973..17b0a6300 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -134,6 +134,13 @@ function buildFileIOSApplicability(object: PbxObject): { } interface PublishableKeyCandidate { + kind: + | "inline-literal" + | "run-scheme" + | "local-secrets-plist" + | "environment-file" + | "keyless-file" + | "ambient-environment"; value?: string; decoded?: { frontendApiHost: string; instanceType: "development" | "production" }; invalid?: true; @@ -248,6 +255,7 @@ async function schemePublishableKeyCandidates( if (!value) continue; const source = relativeIOSPath(root, path); candidates.push({ + kind: "run-scheme", value, source, evidence: [{ path: source, keyPath: "LaunchAction.EnvironmentVariables" }], @@ -319,6 +327,7 @@ async function readPublishableKeyCandidates( for (const line of parseEnvFile(await file.text())) { if (line.type === "entry" && line.key === "CLERK_PUBLISHABLE_KEY" && line.value) { candidates.push({ + kind: "environment-file", value: line.value, source: relativeIOSPath(root, path), evidence: [{ path: relativeIOSPath(root, path), keyPath: line.key }], @@ -340,6 +349,7 @@ async function readPublishableKeyCandidates( const value = isRecord(parsed) ? asString(parsed.CLERK_PUBLISHABLE_KEY) : undefined; if (value) { candidates.push({ + kind: "local-secrets-plist", value, source: relativeIOSPath(root, path), evidence: [{ path: relativeIOSPath(root, path), keyPath: "CLERK_PUBLISHABLE_KEY" }], @@ -347,7 +357,13 @@ async function readPublishableKeyCandidates( }); } } catch { - // Binary/malformed secret files are ignored rather than printing parser data. + candidates.push({ + kind: "local-secrets-plist", + invalid: true, + source: relativeIOSPath(root, path), + evidence: [{ path: relativeIOSPath(root, path), keyPath: "CLERK_PUBLISHABLE_KEY" }], + priority: 10, + }); } } @@ -361,6 +377,7 @@ async function readPublishableKeyCandidates( const value = isRecord(parsed) ? asString(parsed.publishableKey) : undefined; if (value) { candidates.push({ + kind: "keyless-file", value, source: relativeIOSPath(root, path), evidence: [{ path: relativeIOSPath(root, path), keyPath: "publishableKey" }], @@ -375,6 +392,7 @@ async function readPublishableKeyCandidates( const ambient = process.env.CLERK_PUBLISHABLE_KEY; if (ambient) { candidates.push({ + kind: "ambient-environment", value: ambient, source: "CLERK_PUBLISHABLE_KEY environment variable", evidence: [], @@ -392,6 +410,7 @@ async function inspectLocalPublishableKeys( targetLocalSecretsPaths: string[], schemeRoots: string[], inlineCandidates: PublishableKeyCandidate[], + preferredKind: PublishableKeyCandidate["kind"] | undefined, diagnostics: IOSDiagnostic[], ): Promise { const candidates = await readPublishableKeyCandidates( @@ -434,9 +453,12 @@ async function inspectLocalPublishableKeys( const localCandidates = decodedCandidates.filter((item) => !item.candidate.ambient); const ambientCandidates = decodedCandidates.filter((item) => item.candidate.ambient); + // Proven app-init wiring determines which class of candidate can reach the + // selected target. Discovery remains exhaustive and redacted for reporting. const effectivePriority = localCandidates[0]?.candidate.priority; - const effectiveCandidates = - effectivePriority == null + const effectiveCandidates = preferredKind + ? decodedCandidates.filter((item) => item.candidate.kind === preferredKind) + : effectivePriority == null ? ambientCandidates : localCandidates.filter((item) => item.candidate.priority === effectivePriority); @@ -500,6 +522,27 @@ async function inspectLocalPublishableKeys( }; } +function preferredRuntimeKeyCandidateKind( + target: IOSAppTarget | undefined, +): PublishableKeyCandidate["kind"] | undefined { + if (!target?.swift.evidenceComplete) return undefined; + const startupCalls = target.swift.configureCalls.filter( + (call) => call.startupBinding === "app-init", + ); + if (startupCalls.length !== 1) return undefined; + + const call = startupCalls[0]!; + if (call.publishableKeyWiring === "inline-literal") return "inline-literal"; + if ( + call.publishableKeyWiring === "local-secrets-loader" && + call.localSecretsRuntimeBinding === "proven" + ) { + return "local-secrets-plist"; + } + if (call.publishableKeyWiring === "process-info-environment") return "run-scheme"; + return undefined; +} + async function localPackageIsClerk(root: string, packagePath: string): Promise { const manifestPath = resolve(packagePath, "Package.swift"); if (!(await pathIsSafelyWithinIOSRoot(root, manifestPath))) return false; @@ -1622,6 +1665,7 @@ export async function inspectIOSProject( selectedAppTarget?.swift.configureCalls .filter((call) => call.publishableKeyWiring === "inline-literal") .map((call) => ({ + kind: "inline-literal" as const, source: call.path, evidence: [{ path: call.path, keyPath: "Clerk.configure(publishableKey:)" }], priority: 0, @@ -1643,6 +1687,7 @@ export async function inspectIOSProject( ...discovered.workspacePaths, ], inlinePublishableKeyCandidates, + preferredRuntimeKeyCandidateKind(selectedAppTarget), diagnostics, ); const result: IOSProjectInspectionResult = { From 3054f766ad3971dc3c770703ab8ae0ad9d2de631 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 19:49:25 -0400 Subject: [PATCH 16/45] fix(deps): override vulnerable xmldom version --- bun.lock | 3 ++- package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index f06179916..c0b89e1c7 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "playwright-core@1.60.0": "patches/playwright-core@1.60.0.patch", }, "overrides": { + "@xmldom/xmldom": "0.8.15", "tmp": "^0.2.6", }, "packages": { @@ -311,7 +312,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@xmldom/xmldom": ["@xmldom/xmldom@0.7.13", "", {}, "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], diff --git a/package.json b/package.json index 2a9f82875..a97b103bc 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "typescript": "^7" }, "overrides": { + "@xmldom/xmldom": "0.8.15", "tmp": "^0.2.6" }, "nano-staged": { From d9d2b81beeb6b85e771340d8d55d0f3a6b0186b2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:34:25 -0400 Subject: [PATCH 17/45] fix(init): preserve invalid Apple entitlements --- .../src/commands/init/ios/inspect.test.ts | 75 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 22 +++++- .../cli-core/src/commands/init/ios/types.ts | 6 ++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 3aac2db62..e46168baf 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -24,6 +24,15 @@ async function setAssociatedDomainTemplate(root: string, template: string): Prom ); } +async function addAppleEntitlement(root: string, value: string): Promise { + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await Bun.file(entitlementsPath).text(); + await Bun.write( + entitlementsPath, + entitlements.replace("", `com.apple.developer.applesignin${value}\n`), + ); +} + async function addTargetBuildSettings( root: string, settings: Array<[key: string, value: string]>, @@ -185,6 +194,72 @@ describe("inspectIOSProject", () => { }); }); + test("preserves absent, exact, and invalid Apple entitlement states", async () => { + const cases = [ + { + name: "absent", + expectedState: "absent", + expectedExact: false, + }, + { + name: "exact", + value: "Default", + expectedState: "exact", + expectedExact: true, + }, + { + name: "empty array", + value: "", + expectedState: "invalid", + expectedExact: false, + }, + { + name: "wrong value type", + value: "Default", + expectedState: "invalid", + expectedExact: false, + }, + { + name: "non-Default array", + value: "PrimaryApp", + expectedState: "invalid", + expectedExact: false, + }, + { + name: "multi-value array", + value: "DefaultPrimaryApp", + expectedState: "invalid", + expectedExact: false, + }, + ] as const; + + for (const testCase of cases) { + const root = await fixture({ complete: true }); + if ("value" in testCase) await addAppleEntitlement(root, testCase.value); + + const inspection = await inspectIOSProject(root); + const entitlements = inspection.appTargets[0]?.configurations.map( + (configuration) => configuration.entitlements, + ); + + expect(entitlements, testCase.name).toHaveLength(2); + expect( + entitlements?.every( + (value) => + value?.signInWithAppleState === testCase.expectedState && + value.signInWithApple === testCase.expectedExact, + ), + testCase.name, + ).toBe(true); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.invalid-apple-entitlement", + ), + testCase.name, + ).toBe(testCase.expectedState === "invalid"); + } + }); + test("resolves matching associated-domain variables across device and simulator contexts", async () => { const root = await fixture({ complete: true }); await addTargetBuildSettings(root, [ diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 17b0a6300..e07aa8513 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -46,6 +46,7 @@ import type { } from "./types.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; +const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; @@ -686,6 +687,14 @@ function stringArray(value: unknown): string[] { : []; } +function appleEntitlementState( + parsed: Record, +): IOSEntitlementsInspection["signInWithAppleState"] { + if (!Object.hasOwn(parsed, APPLE_SIGN_IN_KEY)) return "absent"; + const value = parsed[APPLE_SIGN_IN_KEY]; + return Array.isArray(value) && value.length === 1 && value[0] === "Default" ? "exact" : "invalid"; +} + async function inspectEntitlements( root: string, absolutePath: string, @@ -714,13 +723,24 @@ async function inspectEntitlements( if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); const applicationIdentifier = asString(parsed["application-identifier"]); + const signInWithAppleState = appleEntitlementState(parsed); + if (signInWithAppleState === "invalid") { + diagnostics.push({ + code: "xcode.invalid-apple-entitlement", + severity: "warning", + message: `${relativePath} has an invalid Sign in with Apple entitlement value.`, + remedy: `Set ${APPLE_SIGN_IN_KEY} to an array containing only Default, then rerun the inspector.`, + evidence: [{ path: relativePath, keyPath: APPLE_SIGN_IN_KEY }], + }); + } return { path: relativePath, associatedDomains: stringArray(parsed["com.apple.developer.associated-domains"]).sort(), unresolvedAssociatedDomains: [], applicationIdentifier, teamIdentifier: asString(parsed["com.apple.developer.team-identifier"]), - signInWithApple: stringArray(parsed["com.apple.developer.applesignin"]).length > 0, + signInWithAppleState, + signInWithApple: signInWithAppleState === "exact", }; } catch { diagnostics.push({ diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 98017d765..9487c448d 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -20,6 +20,7 @@ export interface IOSDiagnostic { | "xcode.conflicting-build-setting" | "xcode.missing-entitlements" | "xcode.unreadable-entitlements" + | "xcode.invalid-apple-entitlement" | "xcode.external-path" | "xcode.generated-project" | "xcode.incomplete-source-membership" @@ -42,6 +43,8 @@ export type IOSValueResolution = } | { state: "missing"; evidence: IOSSourceEvidence[] }; +export type IOSAppleEntitlementState = "absent" | "exact" | "invalid"; + export interface IOSEntitlementsInspection { path: string; associatedDomains: string[]; @@ -50,6 +53,9 @@ export interface IOSEntitlementsInspection { /** Literal prefix candidate from the source plist, validated against the Bundle ID. */ literalAppIdentifierPrefix?: string; teamIdentifier?: string; + /** Whether the entitlement is absent, the exact supported value, or malformed. */ + signInWithAppleState: IOSAppleEntitlementState; + /** True only for the exact supported one-element `Default` array. */ signInWithApple: boolean; } From 1dfad68248ffb0a0170507b5625314cc9af16c1f Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 21:46:13 -0400 Subject: [PATCH 18/45] fix(init): fail closed on incomplete scheme discovery --- .../src/commands/init/ios/inspect.test.ts | 216 ++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 156 ++++++++++--- .../src/commands/init/ios/products.test.ts | 1 + .../cli-core/src/commands/init/ios/types.ts | 3 + 4 files changed, 348 insertions(+), 28 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index e46168baf..f31838ab5 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -62,6 +62,30 @@ async function writeSelectedTargetRunSchemeKey(root: string, value: string): Pro return schemePath; } +function selectedTargetRunScheme(value: string, referencedContainer?: string): string { + const containerAttribute = referencedContainer + ? ` ReferencedContainer="container:${referencedContainer}"` + : ""; + return ``; +} + +async function fillProjectSchemeLimit(root: string, finalScheme: string): Promise { + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Promise.all( + Array.from({ length: 99 }, (_, index) => + Bun.write(join(schemeDirectory, `A${String(index).padStart(3, "0")}.xcscheme`), ""), + ), + ); + await Bun.write(join(schemeDirectory, "ZRuntime.xcscheme"), finalScheme); +} + +async function writeWorkspaceRunScheme(root: string, name: string, source: string): Promise { + const schemeDirectory = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write(join(schemeDirectory, name), source); +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -166,6 +190,7 @@ describe("inspectIOSProject", () => { }); expect(target?.runtimeKeySinks).toEqual([]); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: [".env"], @@ -546,6 +571,7 @@ describe("inspectIOSProject", () => { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: true, source: "MyApp/LocalSecrets.plist", frontendApiHost: "native.clerk.example", @@ -590,6 +616,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root, { target: "MyApp" }); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: true, conflict: false, source: "MyApp/MyAppApp.swift", @@ -631,6 +658,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, source: "MyApp/MyAppApp.swift", conflict: false, @@ -658,6 +686,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: true, found: true, source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", frontendApiHost: "scheme.clerk.example", @@ -666,6 +695,182 @@ struct MyApp: App { expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); + test("fails closed when bounded scheme discovery hides a conflicting workspace key", async () => { + const root = await fixture({ includeKey: false, workspace: true }); + const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleKey)); + await writeWorkspaceRunScheme( + root, + "WorkspaceRuntime.xcscheme", + selectedTargetRunScheme(hiddenKey, "MyApp.xcodeproj"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/ZRuntime.xcscheme"], + invalidSources: [], + }); + expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-scheme-discovery", + severity: "warning", + evidence: expect.arrayContaining([{ path: "MyApp.xcworkspace/xcshareddata" }]), + }), + ); + expect(JSON.stringify(inspection)).not.toContain(visibleKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenKey); + }); + + test("keeps a proven inline key authoritative when scheme discovery is incomplete", async () => { + const root = await fixture({ includeKey: false, workspace: true }); + const inlineKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${inlineKey}") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); + await writeWorkspaceRunScheme( + root, + "WorkspaceRuntime.xcscheme", + selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: true, + found: true, + conflict: false, + source: "MyApp/MyAppApp.swift", + frontendApiHost: "inline.clerk.example", + }); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.incomplete-scheme-discovery", + ), + ).toBe(false); + expect(JSON.stringify(inspection)).not.toContain(inlineKey); + expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); + }); + + test("fails incomplete scheme discovery closed when inline startup wiring is ambiguous", async () => { + const root = await fixture({ includeKey: false, workspace: true }); + const inlineKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${inlineKey}") + Clerk.configure(publishableKey: "${inlineKey}") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); + await writeWorkspaceRunScheme( + root, + "WorkspaceRuntime.xcscheme", + selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift.configureCalls).toHaveLength(2); + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: [ + "MyApp.xcodeproj/xcshareddata/xcschemes/ZRuntime.xcscheme", + "MyApp/MyAppApp.swift", + ], + invalidSources: [], + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-scheme-discovery" }), + ); + expect(JSON.stringify(inspection)).not.toContain(inlineKey); + expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); + }); + + test("keeps a proven LocalSecrets key authoritative when scheme discovery is incomplete", async () => { + const root = await fixture({ + complete: true, + includeKey: false, + localSecrets: true, + workspace: true, + }); + const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); + await writeWorkspaceRunScheme( + root, + "WorkspaceRuntime.xcscheme", + selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: true, + found: true, + conflict: false, + source: "MyApp/LocalSecrets.plist", + frontendApiHost: "native.clerk.example", + }); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.incomplete-scheme-discovery", + ), + ).toBe(false); + expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); + }); + test("uses the LocalSecrets key proven by app-init wiring instead of a different scheme key", async () => { const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; @@ -675,6 +880,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: true, source: "MyApp/LocalSecrets.plist", frontendApiHost: "native.clerk.example", @@ -703,6 +909,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, source: "MyApp/LocalSecrets.plist", conflict: false, @@ -728,6 +935,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], @@ -765,6 +973,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: true, source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", frontendApiHost: "scheme-runtime.clerk.example", @@ -793,6 +1002,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: [], @@ -814,6 +1024,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: [], @@ -836,6 +1047,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: true, source: "MyApp.xcodeproj/xcshareddata/xcschemes/First.xcscheme", conflict: true, @@ -864,6 +1076,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: true, found: true, conflict: false, source: "MyApp/LocalSecrets.plist", @@ -898,6 +1111,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", conflict: false, @@ -917,6 +1131,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: [], @@ -1056,6 +1271,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: true, found: false, conflict: false, candidateSources: [], diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index e07aa8513..e746b24f9 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,4 +1,4 @@ -import { readdir, readFile } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; import { parseEnvFile } from "../../../lib/dotenv.ts"; @@ -50,6 +50,9 @@ const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; +const MAX_SCHEME_DISCOVERY_DEPTH = 6; +const MAX_SCHEME_FILES = 100; +const RUN_SCHEME_PRIORITY = 5; const MAX_SECRET_DISCOVERY_DEPTH = 5; const MAX_SECRET_FILES = 20; const SOURCE_IGNORES = new Set([ @@ -151,30 +154,67 @@ interface PublishableKeyCandidate { ambient?: true; } +interface SchemeDiscoveryState { + paths: string[]; + complete: boolean; + incompletePaths: Set; +} + +function markSchemeDiscoveryIncomplete(state: SchemeDiscoveryState, path: string): void { + state.complete = false; + state.incompletePaths.add(path); +} + async function collectSchemeFiles( root: string, directory: string, - output: string[], + state: SchemeDiscoveryState, depth = 0, ): Promise { - if (depth > 6 || output.length >= 100) return; + if (depth > MAX_SCHEME_DISCOVERY_DEPTH) { + markSchemeDiscoveryIncomplete(state, directory); + return; + } let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch { + markSchemeDiscoveryIncomplete(state, directory); return; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { const path = resolve(directory, entry.name); + if (entry.isSymbolicLink()) { + let couldHideScheme = entry.name.endsWith(".xcscheme"); + if (!couldHideScheme) { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) { + couldHideScheme = true; + } else { + try { + couldHideScheme = (await stat(path)).isDirectory(); + } catch { + couldHideScheme = true; + } + } + } + if (couldHideScheme) markSchemeDiscoveryIncomplete(state, path); + continue; + } if (entry.isDirectory()) { - await collectSchemeFiles(root, path, output, depth + 1); - } else if ( - entry.isFile() && - entry.name.endsWith(".xcscheme") && - (await pathIsSafelyWithinIOSRoot(root, path)) - ) { - output.push(path); + if (state.paths.length >= MAX_SCHEME_FILES) { + markSchemeDiscoveryIncomplete(state, path); + } else { + await collectSchemeFiles(root, path, state, depth + 1); + } + } else if (entry.isFile() && entry.name.endsWith(".xcscheme")) { + if (state.paths.length >= MAX_SCHEME_FILES) { + markSchemeDiscoveryIncomplete(state, path); + } else if (await pathIsSafelyWithinIOSRoot(root, path)) { + state.paths.push(path); + } else { + markSchemeDiscoveryIncomplete(state, path); + } } } } @@ -211,23 +251,39 @@ async function schemePublishableKeyCandidates( root: string, selection: IOSTargetSelection, schemeRoots: string[], -): Promise { - if (selection.state !== "selected") return []; - const schemePaths: string[] = []; +): Promise<{ + candidates: PublishableKeyCandidate[]; + complete: boolean; + incompleteEvidence: IOSSourceEvidence[]; +}> { + if (selection.state !== "selected") { + return { candidates: [], complete: true, incompleteEvidence: [] }; + } + const state: SchemeDiscoveryState = { + paths: [], + complete: true, + incompletePaths: new Set(), + }; for (const schemeRoot of [...new Set(schemeRoots)].sort()) { if (await pathIsSafelyWithinIOSRoot(root, schemeRoot)) { - await collectSchemeFiles(root, schemeRoot, schemePaths); + await collectSchemeFiles(root, schemeRoot, state); + } else { + markSchemeDiscoveryIncomplete(state, schemeRoot); } } const candidates: PublishableKeyCandidate[] = []; - for (const path of schemePaths.sort()) { + for (const path of state.paths.sort()) { const file = Bun.file(path); - if (!(await file.exists()) || file.size > 2_000_000) continue; + if (!(await file.exists()) || file.size > 2_000_000) { + markSchemeDiscoveryIncomplete(state, path); + continue; + } let xml: string; try { xml = maskXMLComments(await file.text()); } catch { + markSchemeDiscoveryIncomplete(state, path); continue; } @@ -260,12 +316,18 @@ async function schemePublishableKeyCandidates( value, source, evidence: [{ path: source, keyPath: "LaunchAction.EnvironmentVariables" }], - priority: 5, + priority: RUN_SCHEME_PRIORITY, }); } } } - return candidates; + return { + candidates, + complete: state.complete, + incompleteEvidence: [...state.incompletePaths] + .sort() + .map((path) => ({ path: relativeIOSPath(root, path) })), + }; } async function collectLocalSecretsPlists( @@ -306,13 +368,18 @@ async function readPublishableKeyCandidates( targetLocalSecretsPaths: string[], schemeRoots: string[], inlineCandidates: PublishableKeyCandidate[], -): Promise { +): Promise<{ + candidates: PublishableKeyCandidate[]; + schemeDiscoveryComplete: boolean; + schemeDiscoveryEvidence: IOSSourceEvidence[]; +}> { const selectedProjectDirectory = selection.state === "selected" ? dirname(resolve(root, selection.projectPath)) : root; const projectDirectories = [...new Set([selectedProjectDirectory, root])]; + const schemeDiscovery = await schemePublishableKeyCandidates(root, selection, schemeRoots); const candidates: PublishableKeyCandidate[] = [ ...inlineCandidates, - ...(await schemePublishableKeyCandidates(root, selection, schemeRoots)), + ...schemeDiscovery.candidates, ]; for (const directory of projectDirectories) { @@ -402,7 +469,13 @@ async function readPublishableKeyCandidates( }); } - return candidates.sort((a, b) => a.priority - b.priority || a.source.localeCompare(b.source)); + return { + candidates: candidates.sort( + (a, b) => a.priority - b.priority || a.source.localeCompare(b.source), + ), + schemeDiscoveryComplete: schemeDiscovery.complete, + schemeDiscoveryEvidence: schemeDiscovery.incompleteEvidence, + }; } async function inspectLocalPublishableKeys( @@ -414,13 +487,14 @@ async function inspectLocalPublishableKeys( preferredKind: PublishableKeyCandidate["kind"] | undefined, diagnostics: IOSDiagnostic[], ): Promise { - const candidates = await readPublishableKeyCandidates( - root, - selection, - targetLocalSecretsPaths, - schemeRoots, - inlineCandidates, - ); + const { candidates, schemeDiscoveryComplete, schemeDiscoveryEvidence } = + await readPublishableKeyCandidates( + root, + selection, + targetLocalSecretsPaths, + schemeRoots, + inlineCandidates, + ); const candidateSources = [...new Set(candidates.map((candidate) => candidate.source))].sort(); const decodedCandidates: Array<{ candidate: PublishableKeyCandidate; @@ -454,6 +528,28 @@ async function inspectLocalPublishableKeys( const localCandidates = decodedCandidates.filter((item) => !item.candidate.ambient); const ambientCandidates = decodedCandidates.filter((item) => item.candidate.ambient); + const runSchemeCouldBeEffective = + !schemeDiscoveryComplete && + preferredKind !== "inline-literal" && + preferredKind !== "local-secrets-plist"; + if (runSchemeCouldBeEffective) { + diagnostics.push({ + code: "xcode.incomplete-scheme-discovery", + severity: "warning", + message: + "Run-scheme discovery was incomplete, so Clerk could not prove the selected target's runtime publishable key.", + remedy: + "Make the selected Xcode project and workspace scheme directories readable, reduce excessive scheme nesting or count, and rerun the command.", + evidence: schemeDiscoveryEvidence, + }); + return { + evidenceComplete: false, + found: false, + conflict: false, + candidateSources, + invalidSources: [...invalidSources].sort(), + }; + } // Proven app-init wiring determines which class of candidate can reach the // selected target. Discovery remains exhaustive and redacted for reporting. const effectivePriority = localCandidates[0]?.candidate.priority; @@ -467,6 +563,7 @@ async function inspectLocalPublishableKeys( // Never fall through to a lower-precedence valid key when that source is malformed. if (effectiveCandidates.some((item) => !item.decoded)) { return { + evidenceComplete: true, found: false, source: effectiveCandidates[0]!.candidate.source, conflict: false, @@ -496,6 +593,7 @@ async function inspectLocalPublishableKeys( evidence: effectiveValid.flatMap((item) => item.candidate.evidence), }); return { + evidenceComplete: true, found: true, source: effective!.candidate.source, conflict: true, @@ -506,6 +604,7 @@ async function inspectLocalPublishableKeys( if (!effective) { return { + evidenceComplete: true, found: false, conflict: false, candidateSources, @@ -513,6 +612,7 @@ async function inspectLocalPublishableKeys( }; } return { + evidenceComplete: true, found: true, conflict: false, source: effective.candidate.source, diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index e2ede8771..76bff763c 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -47,6 +47,7 @@ function inspection(selected: IOSAppTarget): IOSProjectInspectionResult { projectPath: selected.projectPath, }, localPublishableKey: { + evidenceComplete: true, found: false, conflict: false, candidateSources: [], diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 9487c448d..2e0e7624d 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -24,6 +24,7 @@ export interface IOSDiagnostic { | "xcode.external-path" | "xcode.generated-project" | "xcode.incomplete-source-membership" + | "xcode.incomplete-scheme-discovery" | "clerk.package-unattributed" | "clerk.invalid-publishable-key" | "clerk.conflicting-publishable-keys"; @@ -175,6 +176,8 @@ export type IOSTargetSelection = | { state: "none" }; export interface IOSLocalPublishableKeyInspection { + /** False when an effective Run-scheme source may have been hidden by bounded discovery. */ + evidenceComplete: boolean; found: boolean; source?: string; frontendApiHost?: string; From 19271296002725de63a26d94205561dcba760609 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 21:58:26 -0400 Subject: [PATCH 19/45] fix(init): propagate container discovery completeness --- .../src/commands/init/ios/inspect.test.ts | 107 ++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 11 +- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index f31838ab5..36b3db5b2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -86,6 +86,17 @@ async function writeWorkspaceRunScheme(root: string, name: string, source: strin await Bun.write(join(schemeDirectory, name), source); } +async function writeDeepWorkspaceRunScheme(root: string, source: string): Promise { + const workspace = join(root, "One", "Two", "Three", "Four", "Deep.xcworkspace"); + const schemeDirectory = join(workspace, "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + await Bun.write(join(schemeDirectory, "DeepRuntime.xcscheme"), source); +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -742,6 +753,102 @@ struct MyApp: App { expect(JSON.stringify(inspection)).not.toContain(hiddenKey); }); + test("fails closed when bounded container discovery hides a conflicting workspace scheme", async () => { + const root = await fixture({ includeKey: false }); + const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + await writeSelectedTargetRunSchemeKey(root, visibleKey); + await writeDeepWorkspaceRunScheme( + root, + selectedTargetRunScheme(hiddenKey, "../../../../MyApp.xcodeproj"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], + invalidSources: [], + }); + expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-scheme-discovery", + severity: "warning", + evidence: expect.arrayContaining([{ path: "." }]), + }), + ); + expect(JSON.stringify(inspection)).not.toContain(visibleKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenKey); + }); + + test("fails closed when exhaustive container discovery reaches its depth bound", async () => { + const root = await fixture({ includeKey: false }); + const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + await writeSelectedTargetRunSchemeKey(root, visibleKey); + + const nesting = Array.from({ length: 25 }, (_, index) => `Level${index}`); + const workspace = join(root, ...nesting, "Deep.xcworkspace"); + const schemeDirectory = join(workspace, "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const projectReference = `${"../".repeat(nesting.length)}MyApp.xcodeproj`; + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + await Bun.write( + join(schemeDirectory, "DeepRuntime.xcscheme"), + selectedTargetRunScheme(hiddenKey, projectReference), + ); + + const inspection = await inspectIOSProject(root, { exhaustiveContainerDiscovery: true }); + + expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: false, + found: false, + conflict: false, + }); + expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-scheme-discovery" }), + ); + expect(JSON.stringify(inspection)).not.toContain(visibleKey); + expect(JSON.stringify(inspection)).not.toContain(hiddenKey); + }); + test("keeps a proven inline key authoritative when scheme discovery is incomplete", async () => { const root = await fixture({ includeKey: false, workspace: true }); const inlineKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index e746b24f9..57b4290fc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -483,6 +483,7 @@ async function inspectLocalPublishableKeys( selection: IOSTargetSelection, targetLocalSecretsPaths: string[], schemeRoots: string[], + containerDiscoveryComplete: boolean, inlineCandidates: PublishableKeyCandidate[], preferredKind: PublishableKeyCandidate["kind"] | undefined, diagnostics: IOSDiagnostic[], @@ -529,7 +530,7 @@ async function inspectLocalPublishableKeys( const localCandidates = decodedCandidates.filter((item) => !item.candidate.ambient); const ambientCandidates = decodedCandidates.filter((item) => item.candidate.ambient); const runSchemeCouldBeEffective = - !schemeDiscoveryComplete && + (!schemeDiscoveryComplete || !containerDiscoveryComplete) && preferredKind !== "inline-literal" && preferredKind !== "local-secrets-plist"; if (runSchemeCouldBeEffective) { @@ -537,10 +538,13 @@ async function inspectLocalPublishableKeys( code: "xcode.incomplete-scheme-discovery", severity: "warning", message: - "Run-scheme discovery was incomplete, so Clerk could not prove the selected target's runtime publishable key.", + "Xcode container or Run-scheme discovery was incomplete, so Clerk could not prove the selected target's runtime publishable key.", remedy: "Make the selected Xcode project and workspace scheme directories readable, reduce excessive scheme nesting or count, and rerun the command.", - evidence: schemeDiscoveryEvidence, + evidence: [ + ...(!containerDiscoveryComplete ? [{ path: "." }] : []), + ...schemeDiscoveryEvidence, + ], }); return { evidenceComplete: false, @@ -1806,6 +1810,7 @@ export async function inspectIOSProject( ...(selection.state === "selected" ? [resolve(root, selection.projectPath)] : []), ...discovered.workspacePaths, ], + discovered.complete, inlinePublishableKeyCandidates, preferredRuntimeKeyCandidateKind(selectedAppTarget), diagnostics, From 8fd1c01577d3c6c2d1dbf991daf31a734c1178c3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 09:48:07 -0400 Subject: [PATCH 20/45] fix(init): validate Clerk product attribution --- .../src/commands/init/ios/inspect.test.ts | 110 ++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 23 +++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 36b3db5b2..addbdabb2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import type { PbxObjects } from "./pbx.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; @@ -15,6 +17,17 @@ async function fixture(options: Parameters[1] = {}): Pr return root; } +async function transformProject( + root: string, + transform: (objects: PbxObjects) => void, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectPath).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + transform(objects); + await Bun.write(projectPath, buildPbxProject(project)); +} + async function setAssociatedDomainTemplate(root: string, template: string): Promise { const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); const entitlements = await Bun.file(entitlementsPath).text(); @@ -230,6 +243,103 @@ describe("inspectIOSProject", () => { }); }); + test("does not attribute a Clerk product to an unrelated declared clerk-ios package", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + const wrongPackageId = "272727272727272727272727"; + await transformProject(root, (objects) => { + objects[wrongPackageId] = { + isa: "XCRemoteSwiftPackageReference", + repositoryURL: "https://github.com/example/not-clerk", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + }; + objects[IOS_FIXTURE_IDS.project]!.packageReferences = [ + IOS_FIXTURE_IDS.clerkPackage, + wrongPackageId, + ]; + objects[IOS_FIXTURE_IDS.clerkKit]!.package = wrongPackageId; + }); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "unattributed", + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect( + inspection.diagnostics.some((diagnostic) => diagnostic.code === "clerk.package-unattributed"), + ).toBe(true); + }); + + test.each([ + { + name: "another package", + transform(objects: PbxObjects) { + const wrongPackageId = "272727272727272727272727"; + objects[wrongPackageId] = { + isa: "XCRemoteSwiftPackageReference", + repositoryURL: "https://github.com/example/not-clerk", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + }; + objects[IOS_FIXTURE_IDS.clerkKitUI]!.package = wrongPackageId; + }, + }, + { + name: "an unresolved package", + transform(objects: PbxObjects) { + objects[IOS_FIXTURE_IDS.clerkKitUI]!.package = "282828282828282828282828"; + }, + }, + { + name: "no package", + transform(objects: PbxObjects) { + delete objects[IOS_FIXTURE_IDS.clerkKitUI]!.package; + }, + }, + ])("fails closed when Clerk products have mixed attribution to $name", async ({ transform }) => { + const root = await fixture(); + await transformProject(root, transform); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "unattributed", + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test.each(["remote", "local"] as const)( + "preserves the %s Clerk package fallback when all products lack attribution", + async (kind) => { + const root = await fixture({ clerkSDK: "core-only" }); + if (kind === "local") { + await mkdir(join(root, "LocalClerk")); + await Bun.write( + join(root, "LocalClerk", "Package.swift"), + '// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "Clerk", products: [.library(name: "ClerkKit", targets: ["ClerkKit"])], targets: [.target(name: "ClerkKit")])\n', + ); + } + await transformProject(root, (objects) => { + delete objects[IOS_FIXTURE_IDS.clerkKit]!.package; + if (kind === "local") { + objects[IOS_FIXTURE_IDS.clerkPackage] = { + isa: "XCLocalSwiftPackageReference", + relativePath: "LocalClerk", + }; + } + }); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.packages).toEqual({ + package: kind, + clerkKit: "linked", + clerkKitUI: "absent", + }); + }, + ); + test("preserves absent, exact, and invalid Apple entitlement states", async () => { const cases = [ { diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 57b4290fc..eb9d52fc2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -759,17 +759,28 @@ function inspectTargetPackages( const clerkKit = targetProductState(targetObject, objects, "ClerkKit"); const clerkKitUI = targetProductState(targetObject, objects, "ClerkKitUI"); const packageById = new Map(packages.map((item) => [item.objectId, item])); + const productIds = [...clerkKit.productIds, ...clerkKitUI.productIds]; const productPackageIds = [...clerkKit.packageIds, ...clerkKitUI.packageIds]; - const attributed = productPackageIds - .map((id) => packageById.get(id)) - .filter((item): item is IOSPackageReference => item?.isClerk === true); + const uniqueProductPackageIds = new Set(productPackageIds); + // Explicit product attribution is authoritative only when the entire Clerk + // product graph resolves to one verified package. Some workspace-local and + // older Xcode graphs omit every package field, so retain that separate + // declared-package fallback below. + const explicitlyAttributedPackage = + productPackageIds.length === productIds.length && uniqueProductPackageIds.size === 1 + ? packageById.get(productPackageIds[0]!) + : undefined; + const attributedClerkPackage = explicitlyAttributedPackage?.isClerk + ? explicitlyAttributedPackage + : undefined; const declaredClerkPackage = packages.find((item) => item.isClerk); const hasClerkProduct = clerkKit.state !== "absent" || clerkKitUI.state !== "absent"; let packageKind: IOSClerkPackageState["package"] = "absent"; - if (attributed[0]) packageKind = attributed[0].kind; - else if (declaredClerkPackage) packageKind = declaredClerkPackage.kind; - else if (hasClerkProduct) { + if (attributedClerkPackage) packageKind = attributedClerkPackage.kind; + else if (productPackageIds.length === 0 && declaredClerkPackage) { + packageKind = declaredClerkPackage.kind; + } else if (hasClerkProduct) { packageKind = "unattributed"; diagnostics.push({ code: "clerk.package-unattributed", From 589a0d352b06e8d13c27f19d9c7df3b1696b8cb6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 12:03:18 -0400 Subject: [PATCH 21/45] fix(init): recover interrupted iOS file transactions --- .../init/ios/file-transaction.test.ts | 316 ++- .../src/commands/init/ios/file-transaction.ts | 1708 +++++++++++++++-- .../cli-core/src/commands/init/ios/inspect.ts | 2 + 3 files changed, 1832 insertions(+), 194 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts index a384c95db..60ebcede3 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -13,18 +13,20 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { applyIOSExistingFileTransaction, applyIOSFileTransaction, hashIOSFileBytes, IOSFileTransactionError, prepareIOSFileMutationBoundary, + recoverIOSFileTransactions, type IOSCreateFileMutation, type IOSExistingFileMutation, } from "./file-transaction.ts"; const temporaryDirectories: string[] = []; +const FILE_TRANSACTION_MODULE = `${import.meta.dir}/file-transaction.ts`; async function temporaryRoot(): Promise { const root = await mkdtemp(join(tmpdir(), "clerk-ios-file-transaction-")); @@ -80,14 +82,87 @@ async function expectRecoverableClaimedOriginals( root: string, expectedContents: string[], ): Promise { - const transactionFiles = (await readdir(root)).filter((name) => name.includes(".clerk-")); - const claimedOriginals = transactionFiles.filter((name) => name.endsWith(".claimed")); - expect(transactionFiles.filter((name) => name.endsWith(".tmp"))).toEqual([]); + const entries = await readdir(root, { withFileTypes: true }); + const transactionFiles = entries.filter((entry) => entry.name.includes(".clerk-")); + const claimedOriginals = transactionFiles + .filter((entry) => entry.isFile() && entry.name.endsWith(".claimed")) + .map((entry) => join(root, entry.name)); + for (const entry of transactionFiles.filter( + (entry) => entry.isDirectory() && entry.name.endsWith(".recovery"), + )) { + const originalPath = join(root, entry.name, "original"); + if (await Bun.file(originalPath).exists()) claimedOriginals.push(originalPath); + } + expect(transactionFiles.filter((entry) => entry.name.endsWith(".tmp"))).toEqual([]); expect( - (await Promise.all(claimedOriginals.map((name) => readFile(join(root, name), "utf8")))).sort(), + (await Promise.all(claimedOriginals.map((path) => readFile(path, "utf8")))).sort(), ).toEqual([...expectedContents].sort()); } +async function crashFileTransaction( + root: string, + paths: string[], + phase: "lock-publication" | "journal-publication" | "claim" | "rollback-claim" | "committed", + killAfter = 1, +): Promise { + const publicationMarker = join(root, `.publication-${phase}`); + const source = ` + const { readFile, lstat, writeFile } = await import("node:fs/promises"); + const { + applyIOSExistingFileTransaction, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + } = await import(${JSON.stringify(FILE_TRANSACTION_MODULE)}); + const root = ${JSON.stringify(root)}; + const paths = ${JSON.stringify(paths)}; + const mutations = []; + for (const [index, path] of paths.entries()) { + const originalBytes = new Uint8Array(await readFile(path)); + const candidateBytes = new TextEncoder().encode("candidate " + index + "\\n"); + const boundary = await prepareIOSFileMutationBoundary(root, path); + const info = await lstat(path); + mutations.push({ + path, + boundary, + originalBytes, + originalHash: hashIOSFileBytes(originalBytes), + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: info.mode & 0o7777, + }); + } + let claims = 0; + const crash = () => process.kill(process.pid, "SIGKILL"); + const crashDuringPublication = async (path) => { + await writeFile(${JSON.stringify(publicationMarker)}, path, "utf8"); + crash(); + }; + const hooks = + ${JSON.stringify(phase)} === "lock-publication" + ? { beforeRootLockPublication: crashDuringPublication } + : ${JSON.stringify(phase)} === "journal-publication" + ? { beforeRecoveryJournalPublication: crashDuringPublication } + : ${JSON.stringify(phase)} === "claim" + ? { afterExistingDestinationClaim: () => { if (++claims === ${killAfter}) crash(); } } + : ${JSON.stringify(phase)} === "rollback-claim" + ? { afterRollbackDestinationClaim: crash } + : { afterDurableCommit: crash }; + const postconditions = ${JSON.stringify(phase)} === "rollback-claim" + ? [async () => false] + : [async () => true]; + await applyIOSExistingFileTransaction(mutations, postconditions, hooks); + process.exit(97); + `; + const child = Bun.spawn([process.execPath, "-e", source], { + stdout: "ignore", + stderr: "ignore", + }); + await child.exited; + expect(child.signalCode).toBe("SIGKILL"); + if (!phase.endsWith("publication")) return undefined; + return readFile(publicationMarker, "utf8"); +} + async function waitForCondition(condition: () => boolean | Promise): Promise { for (let attempt = 0; attempt < 5_000; attempt++) { if (await condition()) return; @@ -98,7 +173,7 @@ async function waitForCondition(condition: () => boolean | Promise): Pr async function waitForStagingFile(root: string): Promise { await waitForCondition(async () => - (await readdir(root)).some((name) => name.includes(".clerk-")), + (await readdir(root)).some((name) => name.includes(".clerk-") && name.endsWith(".tmp")), ); } @@ -1107,3 +1182,232 @@ describe("iOS create-file transaction", () => { await expectNoTemporaryFiles(root); }); }); + +describe("iOS file transaction crash recovery", () => { + test("does not touch the project root when no interrupted transaction exists", async () => { + const root = await temporaryRoot(); + const before = await lstat(root, { bigint: true }); + + await recoverIOSFileTransactions(root); + + const after = await lstat(root, { bigint: true }); + expect(after.mtimeNs).toBe(before.mtimeNs); + expect(await readdir(root)).toEqual([]); + }); + + test("never publishes a partial root lock when SIGKILL interrupts publication", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + + const lockPath = await crashFileTransaction(root, [path], "lock-publication"); + + expect(lockPath).toBeDefined(); + await expect(lstat(lockPath!)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path, "utf8")).toBe("original source\n"); + const prepared = await mutation(path, "candidate source\n", root); + expect(await applyIOSExistingFileTransaction([prepared], [async () => true])).toEqual({ + status: "applied", + }); + }, 15_000); + + test("never publishes a partial initial journal when SIGKILL interrupts publication", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + + const journalPath = await crashFileTransaction(root, [path], "journal-publication"); + + expect(journalPath).toBeDefined(); + await expect(lstat(journalPath!)).rejects.toMatchObject({ code: "ENOENT" }); + await recoverIOSFileTransactions(root); + expect(await readFile(path, "utf8")).toBe("original source\n"); + const prepared = await mutation(path, "candidate source\n", root); + expect(await applyIOSExistingFileTransaction([prepared], [async () => true])).toEqual({ + status: "applied", + }); + }, 15_000); + + test("does not write through a project root redirected after initial journal publication", async () => { + const base = await temporaryRoot(); + const root = join(base, "project"); + const displacedRoot = join(base, "project-original"); + const outsideRoot = join(base, "outside"); + const path = join(root, "App.swift"); + await mkdir(root); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + await writeFile(join(outsideRoot, "sentinel"), "outside bytes\n"); + const prepared = await mutation(path, "candidate source\n", root); + let outsideJournalPath = ""; + let outsideMtime: bigint | undefined; + let outsideEntries: string[] = []; + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], [async () => true], { + beforeRecoveryJournalPublication: async (journalPath) => { + outsideJournalPath = join(outsideRoot, basename(journalPath)); + await writeFile(outsideJournalPath, "outside journal bytes\n"); + }, + afterInitialRecoveryJournalPublication: async () => { + await rename(root, displacedRoot); + await symlink(outsideRoot, root, "dir"); + outsideMtime = (await lstat(outsideRoot, { bigint: true })).mtimeNs; + outsideEntries = (await readdir(outsideRoot)).sort(); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("stage-failed"); + expect(outsideMtime).toBeDefined(); + const outsideAfter = await lstat(outsideRoot, { bigint: true }); + expect(outsideAfter.mtimeNs).toBe(outsideMtime!); + expect((await readdir(outsideRoot)).sort()).toEqual(outsideEntries); + expect(await readFile(outsideJournalPath, "utf8")).toBe("outside journal bytes\n"); + expect(await readFile(join(outsideRoot, "sentinel"), "utf8")).toBe("outside bytes\n"); + expect(await readFile(join(displacedRoot, "App.swift"), "utf8")).toBe("original source\n"); + expect((await readdir(displacedRoot)).some((name) => name.endsWith(".journal"))).toBe(true); + }); + + test("does not write through a project root redirected before lock publication", async () => { + const base = await temporaryRoot(); + const root = join(base, "project"); + const displacedRoot = join(base, "project-original"); + const outsideRoot = join(base, "outside"); + const path = join(root, "App.swift"); + await mkdir(root); + await mkdir(outsideRoot); + await writeFile(path, "original source\n"); + await writeFile(join(outsideRoot, "sentinel"), "outside bytes\n"); + const prepared = await mutation(path, "candidate source\n", root); + const outsideBefore = await lstat(outsideRoot, { bigint: true }); + + const result = await applyIOSExistingFileTransaction([prepared], [async () => true], { + beforeRootLockPublication: async () => { + await rename(root, displacedRoot); + await symlink(outsideRoot, root, "dir"); + }, + }); + + const outsideAfter = await lstat(outsideRoot, { bigint: true }); + expect(result).toEqual({ status: "stale" }); + expect(outsideAfter.mtimeNs).toBe(outsideBefore.mtimeNs); + expect(await readFile(join(outsideRoot, "sentinel"), "utf8")).toBe("outside bytes\n"); + expect(await readdir(outsideRoot)).toEqual(["sentinel"]); + expect(await readFile(join(displacedRoot, "App.swift"), "utf8")).toBe("original source\n"); + }); + + test("never overwrites an occupied claim inside the transaction-owned directory", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n", root); + let occupiedClaimPath = ""; + + let caught: unknown; + try { + await applyIOSExistingFileTransaction([prepared], [async () => true], { + afterRecoveryJournalPublished: async (journalPath) => { + const journal = JSON.parse(await readFile(journalPath, "utf8")) as { + mutations: Array<{ originalClaimPath: string }>; + }; + occupiedClaimPath = journal.mutations[0]!.originalClaimPath; + await writeFile(occupiedClaimPath, "newer claim occupant\n"); + }, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("commit-failed"); + expect(await readFile(path, "utf8")).toBe("original source\n"); + expect(await readFile(occupiedClaimPath, "utf8")).toBe("newer claim occupant\n"); + }); + + test("restores a destination after SIGKILL immediately follows its claim", async () => { + const root = await temporaryRoot(); + const path = join(root, "project.pbxproj"); + await writeFile(path, "original project\n"); + + await crashFileTransaction(root, [path], "claim"); + + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await readdir(root)).some((name) => name.endsWith(".journal"))).toBe(true); + await recoverIOSFileTransactions(root); + expect(await readFile(path, "utf8")).toBe("original project\n"); + await expectNoTemporaryFiles(root); + }, 15_000); + + test("rolls back every file after SIGKILL interrupts a later claim", async () => { + const root = await temporaryRoot(); + const firstPath = join(root, "project.pbxproj"); + const secondPath = join(root, "App.swift"); + await writeFile(firstPath, "original project\n"); + await writeFile(secondPath, "original source\n"); + + await crashFileTransaction(root, [firstPath, secondPath], "claim", 2); + + expect(await readFile(firstPath, "utf8")).toBe("candidate 0\n"); + await expect(lstat(secondPath)).rejects.toMatchObject({ code: "ENOENT" }); + await recoverIOSFileTransactions(root); + expect(await readFile(firstPath, "utf8")).toBe("original project\n"); + expect(await readFile(secondPath, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(root); + }, 15_000); + + test("recovers SIGKILL during rollback without leaving the destination absent", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + + await crashFileTransaction(root, [path], "rollback-claim"); + + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + await recoverIOSFileTransactions(root); + expect(await readFile(path, "utf8")).toBe("original source\n"); + await expectNoTemporaryFiles(root); + }, 15_000); + + test("keeps committed bytes after SIGKILL interrupts artifact cleanup", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + + await crashFileTransaction(root, [path], "committed"); + + expect(await readFile(path, "utf8")).toBe("candidate 0\n"); + await recoverIOSFileTransactions(root); + expect(await readFile(path, "utf8")).toBe("candidate 0\n"); + await expectNoTemporaryFiles(root); + }, 15_000); + + test("rejects recovery when a prepared parent is redirected outside its canonical root", async () => { + const base = await temporaryRoot(); + const root = join(base, "project"); + const parent = join(root, "Sources"); + const displacedParent = join(base, "outside-Sources"); + const path = join(parent, "App.swift"); + await mkdir(parent, { recursive: true }); + await writeFile(path, "original source\n"); + + await crashFileTransaction(root, [path], "claim"); + await rename(parent, displacedParent); + await symlink(displacedParent, parent, "dir"); + + let caught: unknown; + try { + await recoverIOSFileTransactions(root); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(IOSFileTransactionError); + expect((caught as IOSFileTransactionError).code).toBe("recovery-failed"); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await readdir(displacedParent)).some((name) => name.endsWith(".recovery"))).toBe(true); + }, 15_000); +}); diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts index 2629b9b74..8bc500cd9 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -1,5 +1,18 @@ -import { link, lstat, open, readFile, realpath, rename, rm } from "node:fs/promises"; +import { + link, + lstat, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + rm, + rmdir, +} from "node:fs/promises"; import { randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; /** @@ -61,7 +74,8 @@ export type IOSFileTransactionErrorCode = | "stage-failed" | "cleanup-failed" | "commit-failed" - | "rollback-failed"; + | "rollback-failed" + | "recovery-failed"; /** A fixed-message failure that never carries mutation contents. */ export class IOSFileTransactionError extends Error { @@ -82,6 +96,13 @@ interface StagedMutation { stagedIdentity: FileIdentity; committedIdentity?: FileIdentity; claimedOriginal?: ClaimedDestination; + originalClaimPath?: string; + rollbackClaimPath?: string; + recoveryDirectory?: { + path: string; + present: boolean; + identity: DirectoryIdentity; + }; } interface ClaimedDestination { @@ -101,16 +122,73 @@ interface DirectoryIdentity { inode: number; } +interface IOSFileTransactionJournalMutation { + kind: "create" | "existing"; + destinationPath: string; + temporaryPath: string; + originalClaimPath?: string; + rollbackClaimPath: string; + originalHash?: string; + candidateHash: string; + mode: number; + boundary: IOSFileMutationBoundary; + recoveryDirectoryPath: string; + recoveryDirectoryIdentity: DirectoryIdentity; +} + +interface IOSFileTransactionJournalRecord { + schemaVersion: 1; + kind: "clerk-ios-file-transaction"; + transactionId: string; + processId: number; + rootPath: string; + state: "pending" | "committed"; + mutations: IOSFileTransactionJournalMutation[]; +} + +interface IOSFileTransactionJournal { + path: string; + nextPath: string; + record: IOSFileTransactionJournalRecord; + present: boolean; + identity?: FileIdentity; + hash?: string; +} + class IOSFileTransactionStaleError extends Error {} class IOSFileTransactionOwnershipError extends Error {} +class IOSFileTransactionUnsafeSetupCleanupError extends Error { + constructor(cause: unknown) { + super("iOS file transaction recovery setup could not be cleaned up safely", { cause }); + } +} + +const JOURNAL_PREFIX = ".clerk-ios-file-transaction-"; +const JOURNAL_SUFFIX = ".journal"; +const MAX_JOURNAL_BYTES = 1_000_000; +const activeJournalPaths = new Set(); +const recoveryByRoot = new Map>(); +const rootLockContext = new AsyncLocalStorage>(); +const JOURNAL_NAME_PATTERN = + /^\.clerk-ios-file-transaction-([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.journal$/i; +const ROOT_LOCK_DIRECTORY_NAME = `.clerk-ios-file-transaction-locks-${ + typeof process.getuid === "function" ? process.getuid() : "user" +}`; + /** * Deterministic race hooks used only by the file-transaction regression tests. * * @internal */ export interface IOSFileTransactionTestHooks { + beforeRootLockPublication?: (path: string, temporaryPath: string) => void | Promise; + beforeRecoveryJournalPublication?: (path: string, temporaryPath: string) => void | Promise; + afterInitialRecoveryJournalPublication?: (journalPath: string) => void | Promise; + afterRecoveryJournalPublished?: (journalPath: string) => void | Promise; + afterDurableCommit?: (journalPath: string) => void | Promise; + afterCommittedArtifactCleanup?: (journalPath: string) => void | Promise; beforeExistingDestinationClaim?: (path: string) => void | Promise; afterExistingDestinationClaim?: (path: string, claimPath: string) => void | Promise; beforeExistingDestinationInstall?: (path: string, claimPath: string) => void | Promise; @@ -141,17 +219,12 @@ function aggregateCause(errors: unknown[]): unknown { return errors.length === 1 ? errors[0] : new AggregateError(errors); } -async function syncDirectory(path: string): Promise { +async function syncDirectoryStrict(path: string): Promise { + const directory = await open(path, "r"); try { - const directory = await open(path, "r"); - try { - await directory.sync(); - } finally { - await directory.close(); - } - } catch { - // Same-directory rename remains atomic even when directory fsync is not - // available on the current filesystem. + await directory.sync(); + } finally { + await directory.close(); } } @@ -217,129 +290,1272 @@ function sameFile(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino; } -function directoryIdentitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean { - return left.device === right.device && left.inode === right.inode; -} +function directoryIdentitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean { + return left.device === right.device && left.inode === right.inode; +} + +function pathIsWithin(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function readCurrentMutationBoundary( + rootPath: string, + parentPath: string, +): Promise | undefined> { + try { + const realRootPath = await realpath(rootPath); + const realParentPath = await realpath(parentPath); + if (!pathIsWithin(realRootPath, realParentPath)) return undefined; + + const rootIdentity = await readDirectoryIdentity(realRootPath); + const parentIdentity = await readDirectoryIdentity(realParentPath); + if (!rootIdentity || !parentIdentity) return undefined; + + const realRootAfterRead = await realpath(rootPath); + const realParentAfterRead = await realpath(parentPath); + const rootIdentityAfterRead = await readDirectoryIdentity(realRootAfterRead); + const parentIdentityAfterRead = await readDirectoryIdentity(realParentAfterRead); + if ( + realRootAfterRead !== realRootPath || + realParentAfterRead !== realParentPath || + !rootIdentityAfterRead || + !parentIdentityAfterRead || + !directoryIdentitiesMatch(rootIdentityAfterRead, rootIdentity) || + !directoryIdentitiesMatch(parentIdentityAfterRead, parentIdentity) + ) { + return undefined; + } + + return { realRootPath, rootIdentity, realParentPath, parentIdentity }; + } catch { + return undefined; + } +} + +/** + * Captures the root and parent directory authorized for a prepared mutation. + * Both paths are resolved twice around the identity reads so a moving or + * replaced directory is rejected instead of being recorded inconsistently. + * + * @internal The returned evidence belongs only in hidden mutation state. + */ +export async function prepareIOSFileMutationBoundary( + rootInput: string, + mutationPathInput: string, +): Promise { + const rootPath = resolve(rootInput); + const mutationPath = resolve(mutationPathInput); + if (!pathIsWithin(rootPath, mutationPath)) return undefined; + + const current = await readCurrentMutationBoundary(rootPath, dirname(mutationPath)); + return current ? { rootPath, ...current } : undefined; +} + +async function mutationBoundaryStillMatches(mutation: IOSFileMutation): Promise { + const { boundary } = mutation; + if (!pathIsWithin(boundary.rootPath, mutation.path)) return false; + const current = await readCurrentMutationBoundary(boundary.rootPath, dirname(mutation.path)); + return ( + current !== undefined && + current.realRootPath === boundary.realRootPath && + current.realParentPath === boundary.realParentPath && + directoryIdentitiesMatch(current.rootIdentity, boundary.rootIdentity) && + directoryIdentitiesMatch(current.parentIdentity, boundary.parentIdentity) + ); +} + +async function mutationBoundariesStillMatch( + mutations: readonly IOSFileMutation[], +): Promise { + const matches = await Promise.all( + mutations.map(async (mutation) => mutationBoundaryStillMatches(mutation)), + ); + return matches.every(Boolean); +} + +async function fileMatchesIdentityAndHash( + path: string, + expectedIdentity: FileIdentity, + expectedHash: string, +): Promise { + try { + const beforeRead = await readRegularFileIdentity(path); + if (!beforeRead || !identitiesMatch(beforeRead, expectedIdentity)) return false; + const matchesHash = hashIOSFileBytes(await readFile(path)) === expectedHash; + const afterRead = await readRegularFileIdentity(path); + return matchesHash && afterRead !== undefined && identitiesMatch(afterRead, expectedIdentity); + } catch { + return false; + } +} + +async function pathIsAbsent(path: string): Promise { + try { + await lstat(path); + return false; + } catch (error) { + return isFileSystemError(error, "ENOENT"); + } +} + +function isFileSystemError(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function transactionSiblingPath( + path: string, + purpose: string, + transactionId?: string, + index?: number, +): string { + const owner = + transactionId === undefined + ? `${process.pid}-${randomUUID()}` + : `${transactionId}-${index ?? 0}`; + return resolve(dirname(path), `.${basename(path)}.clerk-${owner}.${purpose}`); +} + +function transactionRoot(mutations: readonly IOSFileMutation[]): string { + let rootPath = mutations[0]!.boundary.rootPath; + while (mutations.some((mutation) => !pathIsWithin(rootPath, mutation.path))) { + const parentPath = dirname(rootPath); + if (parentPath === rootPath) { + throw transactionError( + "invalid-mutation", + "The iOS file transaction did not have a usable shared recovery root.", + ); + } + rootPath = parentPath; + } + return rootPath; +} + +async function stableRootLockKey(rootPath: string): Promise { + const current = await readCurrentMutationBoundary(rootPath, rootPath); + return current?.rootIdentity; +} + +function transactionRecoveryDirectoryPath( + destinationPath: string, + transactionId: string, + index: number, +): string { + return resolve( + dirname(destinationPath), + `.${basename(destinationPath)}.clerk-${transactionId}-${index}.recovery`, + ); +} + +function journalPath(rootPath: string, transactionId: string): string { + return resolve(rootPath, `${JOURNAL_PREFIX}${transactionId}${JOURNAL_SUFFIX}`); +} + +async function writeExclusiveSyncedFile( + path: string, + bytes: Uint8Array, + mode: number, + beforePublication?: (path: string, temporaryPath: string) => void | Promise, +): Promise { + const temporaryPath = transactionSiblingPath(path, "publication.tmp"); + let temporaryIdentity: FileIdentity | undefined; + let published = false; + try { + const file = await open(temporaryPath, "wx", mode); + try { + await file.writeFile(bytes); + await file.chmod(mode); + await file.sync(); + const info = await file.stat(); + if (!info.isFile()) throw new Error("a publication source was not a regular file"); + temporaryIdentity = { dev: info.dev, ino: info.ino, mode: info.mode & 0o7777 }; + } finally { + await file.close(); + } + await beforePublication?.(path, temporaryPath); + await link(temporaryPath, path); + published = true; + const publishedIdentity = await readRegularFileIdentity(path); + const temporaryIdentityAfterLink = await readRegularFileIdentity(temporaryPath); + if ( + !temporaryIdentity || + !publishedIdentity || + !temporaryIdentityAfterLink || + !identitiesMatch(temporaryIdentity, publishedIdentity) || + !identitiesMatch(temporaryIdentity, temporaryIdentityAfterLink) + ) { + throw new IOSFileTransactionOwnershipError( + "an atomically published transaction file could not be identified", + ); + } + await syncDirectoryStrict(dirname(path)); + } catch (error) { + const cleanupErrors: unknown[] = []; + if (published && temporaryIdentity) { + try { + const publishedIdentity = await readRegularFileIdentity(path); + if (publishedIdentity && sameFile(publishedIdentity, temporaryIdentity)) { + await rm(path); + await syncDirectoryStrict(dirname(path)); + } + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + if (temporaryIdentity) { + try { + const currentIdentity = await readRegularFileIdentity(temporaryPath); + if (currentIdentity && sameFile(currentIdentity, temporaryIdentity)) { + await rm(temporaryPath); + await syncDirectoryStrict(dirname(temporaryPath)); + } + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + throw cleanupErrors.length > 0 ? aggregateCause([error, ...cleanupErrors]) : error; + } + try { + await rm(temporaryPath); + await syncDirectoryStrict(dirname(temporaryPath)); + } catch { + // Publication is already complete and directory-synced. A retained source + // hard link is harmless and must not poison a live lock or journal. + } + if (!temporaryIdentity) { + throw new IOSFileTransactionOwnershipError( + "an atomically published transaction file did not retain its identity", + ); + } + return temporaryIdentity; +} + +interface RootLockLease { + path: string; + identity: FileIdentity; + hash: string; + directoryPath: string; +} + +type RootLockKey = DirectoryIdentity; + +function rootLockKeyName(key: RootLockKey): string { + return `${key.device}-${key.inode}`; +} + +async function rootLockDirectory(): Promise { + const path = resolve(tmpdir(), ROOT_LOCK_DIRECTORY_NAME); + let created = false; + try { + await mkdir(path, { mode: 0o700 }); + created = true; + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error; + } + const info = await lstat(path); + const expectedUserId = typeof process.getuid === "function" ? process.getuid() : undefined; + if ( + !info.isDirectory() || + info.isSymbolicLink() || + (info.mode & 0o077) !== 0 || + (expectedUserId !== undefined && info.uid !== expectedUserId) + ) { + throw new IOSFileTransactionOwnershipError( + "the private iOS transaction lock directory was not safely owned", + ); + } + if (created) await syncDirectoryStrict(dirname(path)); + return path; +} + +async function readRootLock( + path: string, + expectedKey: RootLockKey, +): Promise<{ processId: number; identity: FileIdentity; hash: string }> { + const beforeRead = await readRegularFileIdentity(path); + if (!beforeRead) throw new Error("invalid iOS file transaction lock"); + const contents = await readFile(path, "utf8"); + if (Buffer.byteLength(contents) > 4_096) throw new Error("oversized iOS file transaction lock"); + const afterRead = await readRegularFileIdentity(path); + if (!afterRead || !identitiesMatch(beforeRead, afterRead)) { + throw new Error("the iOS file transaction lock changed while it was read"); + } + const value: unknown = JSON.parse(contents); + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + value.kind !== "clerk-ios-file-transaction-lock" || + !Number.isSafeInteger(value.processId) || + (value.processId as number) <= 0 || + value.rootDevice !== expectedKey.device || + value.rootInode !== expectedKey.inode || + typeof value.token !== "string" || + !/^[0-9a-f-]{36}$/i.test(value.token) + ) { + throw new Error("invalid iOS file transaction lock contents"); + } + return { + processId: value.processId as number, + identity: afterRead, + hash: hashIOSFileBytes(contents), + }; +} + +async function acquireRootLock( + key: RootLockKey, + beforePublication?: (path: string, temporaryPath: string) => void | Promise, +): Promise { + const directoryPath = await rootLockDirectory(); + const path = resolve(directoryPath, `root-${rootLockKeyName(key)}.lock`); + for (;;) { + const contents = `${JSON.stringify({ + schemaVersion: 1, + kind: "clerk-ios-file-transaction-lock", + processId: process.pid, + rootDevice: key.device, + rootInode: key.inode, + token: randomUUID(), + })}\n`; + try { + await writeExclusiveSyncedFile( + path, + new TextEncoder().encode(contents), + 0o600, + beforePublication, + ); + const identity = await readRegularFileIdentity(path); + if (!identity) throw new Error("the iOS file transaction lock could not be identified"); + return { path, identity, hash: hashIOSFileBytes(contents), directoryPath }; + } catch (error) { + if (!isFileSystemError(error, "EEXIST")) throw error; + } + + const lock = await readRootLock(path, key); + if (processIsAlive(lock.processId)) { + // A recycled PID may conservatively block recovery, but never permits + // this process to steal a lock that could belong to a live transaction. + throw new IOSFileTransactionOwnershipError( + "another process still owns the iOS file transaction lock", + ); + } + const claimPath = transactionSiblingPath(path, "stale.claimed"); + const claim = await claimDestination(path, lock.identity, lock.hash, claimPath); + if (claim.status === "stale") continue; + await removeClaimedPath(claim.claim, { + expectedHash: lock.hash, + expectedMode: lock.identity.mode, + }); + await syncDirectoryStrict(directoryPath); + } +} + +async function releaseRootLock(lock: RootLockLease): Promise { + await removeClaimedPath( + { path: lock.path, present: true, identity: lock.identity }, + { expectedHash: lock.hash, expectedMode: lock.identity.mode }, + ); + await syncDirectoryStrict(lock.directoryPath); +} + +async function withRootLock( + key: RootLockKey, + operation: () => Promise, + beforePublication?: (path: string, temporaryPath: string) => void | Promise, +): Promise { + const keyName = rootLockKeyName(key); + const heldRoots = rootLockContext.getStore(); + if (heldRoots?.has(keyName)) return operation(); + + let lock: RootLockLease; + try { + lock = await acquireRootLock(key, beforePublication); + } catch (error) { + throw transactionError( + "recovery-failed", + "The iOS file transaction recovery lock could not be acquired.", + error, + ); + } + const nestedRoots = new Set(heldRoots); + nestedRoots.add(keyName); + try { + return await rootLockContext.run(nestedRoots, operation); + } finally { + await releaseRootLock(lock); + } +} + +function journalBytes(record: IOSFileTransactionJournalRecord): Uint8Array { + const bytes = new TextEncoder().encode(`${JSON.stringify(record)}\n`); + if (bytes.byteLength > MAX_JOURNAL_BYTES) { + throw new Error("the iOS file transaction recovery journal was too large"); + } + return bytes; +} + +async function removeRecoveryDirectory( + path: string, + expectedIdentity: DirectoryIdentity, +): Promise { + let info; + try { + info = await lstat(path); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return; + throw error; + } + if ( + !info.isDirectory() || + info.isSymbolicLink() || + info.dev !== expectedIdentity.device || + info.ino !== expectedIdentity.inode + ) { + throw new IOSFileTransactionOwnershipError( + "an iOS recovery directory no longer identified the transaction's directory", + ); + } + await rmdir(path); + await syncDirectoryStrict(dirname(path)); +} + +async function removeStagedRecoveryDirectories(staged: readonly StagedMutation[]): Promise { + const errors: unknown[] = []; + for (const item of [...staged].reverse()) { + if (!item.recoveryDirectory?.present) continue; + try { + await removeRecoveryDirectory(item.recoveryDirectory.path, item.recoveryDirectory.identity); + item.recoveryDirectory.present = false; + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw aggregateCause(errors); +} + +async function removeJournalRecoveryDirectories( + record: IOSFileTransactionJournalRecord, +): Promise { + for (const mutation of [...record.mutations].reverse()) { + await removeRecoveryDirectory( + mutation.recoveryDirectoryPath, + mutation.recoveryDirectoryIdentity, + ); + } +} + +async function cleanupInitialJournalSetupIfAuthorized( + journal: IOSFileTransactionJournal, + staged: readonly StagedMutation[], +): Promise { + const mutations = staged.map((item) => item.mutation); + if (!(await mutationBoundariesStillMatch(mutations))) return false; + + if (journal.present) { + if ( + !journal.identity || + !journal.hash || + !(await fileMatchesIdentityAndHash(journal.path, journal.identity, journal.hash)) || + !(await mutationBoundariesStillMatch(mutations)) + ) { + return false; + } + await rm(journal.path); + journal.present = false; + if (!(await mutationBoundariesStillMatch(mutations))) return false; + await syncDirectoryStrict(journal.record.rootPath); + } + + if (!(await mutationBoundariesStillMatch(mutations))) return false; + await removeStagedRecoveryDirectories(staged); + return mutationBoundariesStillMatch(mutations); +} + +async function createTransactionJournal( + staged: readonly StagedMutation[], + beforePublication?: (path: string, temporaryPath: string) => void | Promise, + afterPublication?: (journalPath: string) => void | Promise, +): Promise { + const rootPath = transactionRoot(staged.map((item) => item.mutation)); + const transactionId = randomUUID(); + try { + for (const [index, item] of staged.entries()) { + if (!(await mutationBoundaryStillMatches(item.mutation))) { + throw new IOSFileTransactionStaleError(); + } + const recoveryDirectoryPath = transactionRecoveryDirectoryPath( + item.mutation.path, + transactionId, + index, + ); + await mkdir(recoveryDirectoryPath, { mode: 0o700 }); + const recoveryDirectoryIdentity = await readDirectoryIdentity(recoveryDirectoryPath); + if (!recoveryDirectoryIdentity) { + throw new Error("an iOS recovery directory could not be identified"); + } + item.recoveryDirectory = { + path: recoveryDirectoryPath, + present: true, + identity: recoveryDirectoryIdentity, + }; + if (!(await mutationBoundaryStillMatches(item.mutation))) { + await removeStagedRecoveryDirectories(staged); + throw new IOSFileTransactionStaleError(); + } + item.rollbackClaimPath = resolve(recoveryDirectoryPath, "candidate"); + if (!isCreateMutation(item.mutation)) { + item.originalClaimPath = resolve(recoveryDirectoryPath, "original"); + } + await syncDirectoryStrict(recoveryDirectoryPath); + await syncDirectoryStrict(dirname(recoveryDirectoryPath)); + } + } catch (error) { + await removeStagedRecoveryDirectories(staged); + throw error; + } + const record: IOSFileTransactionJournalRecord = { + schemaVersion: 1, + kind: "clerk-ios-file-transaction", + transactionId, + processId: process.pid, + rootPath, + state: "pending", + mutations: staged.map((item) => ({ + kind: isCreateMutation(item.mutation) ? "create" : "existing", + destinationPath: item.mutation.path, + temporaryPath: item.temporaryPath, + originalClaimPath: item.originalClaimPath, + rollbackClaimPath: item.rollbackClaimPath!, + originalHash: isCreateMutation(item.mutation) ? undefined : item.mutation.originalHash, + candidateHash: item.mutation.candidateHash, + mode: item.mutation.mode, + recoveryDirectoryPath: item.recoveryDirectory!.path, + recoveryDirectoryIdentity: { ...item.recoveryDirectory!.identity }, + boundary: { + rootPath: item.mutation.boundary.rootPath, + realRootPath: item.mutation.boundary.realRootPath, + rootIdentity: { ...item.mutation.boundary.rootIdentity }, + realParentPath: item.mutation.boundary.realParentPath, + parentIdentity: { ...item.mutation.boundary.parentIdentity }, + }, + })), + }; + const path = journalPath(rootPath, transactionId); + const bytes = journalBytes(record); + const journal: IOSFileTransactionJournal = { + path, + nextPath: `${path}.next`, + record, + present: false, + hash: hashIOSFileBytes(bytes), + }; + + try { + const stagedDirectories = new Set(staged.map((item) => dirname(item.temporaryPath))); + for (const directory of stagedDirectories) await syncDirectoryStrict(directory); + if (!(await mutationBoundariesStillMatch(staged.map((item) => item.mutation)))) { + throw new IOSFileTransactionStaleError(); + } + journal.identity = await writeExclusiveSyncedFile(path, bytes, 0o600, async (...args) => { + await beforePublication?.(...args); + if (!(await mutationBoundariesStillMatch(staged.map((item) => item.mutation)))) { + throw new IOSFileTransactionStaleError(); + } + }); + journal.present = true; + await afterPublication?.(path); + if (!(await mutationBoundariesStillMatch(staged.map((item) => item.mutation)))) { + throw new IOSFileTransactionStaleError(); + } + activeJournalPaths.add(path); + return journal; + } catch (error) { + let cleanupIsSafe: boolean; + try { + cleanupIsSafe = await cleanupInitialJournalSetupIfAuthorized(journal, staged); + } catch (cleanupError) { + if (await mutationBoundariesStillMatch(staged.map((item) => item.mutation))) { + throw aggregateCause([error, cleanupError]); + } + throw new IOSFileTransactionUnsafeSetupCleanupError(aggregateCause([error, cleanupError])); + } + if (cleanupIsSafe) throw error; + // The lexical root or a destination parent no longer identifies the + // authorized directories. Keep a published no-op journal and its staged + // artifacts intact so a later invocation can recover them after the + // original boundary is restored. + throw new IOSFileTransactionUnsafeSetupCleanupError(error); + } +} + +async function markTransactionCommitted(journal: IOSFileTransactionJournal): Promise { + const committed: IOSFileTransactionJournalRecord = { + ...journal.record, + state: "committed", + }; + try { + await rm(journal.nextPath, { force: true }); + await writeExclusiveSyncedFile(journal.nextPath, journalBytes(committed), 0o600); + await rename(journal.nextPath, journal.path); + journal.record = committed; + await syncDirectoryStrict(committed.rootPath); + } catch (error) { + try { + await rm(journal.nextPath, { force: true }); + } catch { + // A pending journal remains authoritative until the atomic rename. + } + throw error; + } +} + +async function removeTransactionJournal(journal: IOSFileTransactionJournal): Promise { + try { + await removeJournalRecoveryDirectories(journal.record); + await rm(journal.nextPath, { force: true }); + if (journal.present) { + await rm(journal.path); + journal.present = false; + } + await syncDirectoryStrict(journal.record.rootPath); + } finally { + activeJournalPaths.delete(journal.path); + } +} + +type RecoveryFileState = + | { kind: "absent" } + | { kind: "file"; identity: FileIdentity; hash: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSHA256(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value); +} + +function isExpectedTemporaryPath(path: string, destinationPath: string): boolean { + const name = basename(path); + return ( + dirname(path) === dirname(destinationPath) && + name.startsWith(`.${basename(destinationPath)}.clerk-`) && + name.endsWith(".tmp") + ); +} + +function parseRecoveryBoundary( + value: unknown, + transactionRootPath: string, + destinationPath: string, +): IOSFileMutationBoundary { + if ( + !isRecord(value) || + typeof value.rootPath !== "string" || + typeof value.realRootPath !== "string" || + typeof value.realParentPath !== "string" || + !isRecord(value.rootIdentity) || + !Number.isSafeInteger(value.rootIdentity.device) || + (value.rootIdentity.device as number) < 0 || + !Number.isSafeInteger(value.rootIdentity.inode) || + (value.rootIdentity.inode as number) < 0 || + !isRecord(value.parentIdentity) || + !Number.isSafeInteger(value.parentIdentity.device) || + (value.parentIdentity.device as number) < 0 || + !Number.isSafeInteger(value.parentIdentity.inode) || + (value.parentIdentity.inode as number) < 0 + ) { + throw new Error("invalid iOS file transaction recovery boundary"); + } + const boundary: IOSFileMutationBoundary = { + rootPath: resolve(value.rootPath), + realRootPath: resolve(value.realRootPath), + rootIdentity: { + device: value.rootIdentity.device as number, + inode: value.rootIdentity.inode as number, + }, + realParentPath: resolve(value.realParentPath), + parentIdentity: { + device: value.parentIdentity.device as number, + inode: value.parentIdentity.inode as number, + }, + }; + if ( + boundary.rootPath !== value.rootPath || + boundary.realRootPath !== value.realRootPath || + boundary.realParentPath !== value.realParentPath || + !pathIsWithin(transactionRootPath, boundary.rootPath) || + !pathIsWithin(boundary.rootPath, destinationPath) || + !pathIsWithin(boundary.realRootPath, boundary.realParentPath) + ) { + throw new Error("unsafe iOS file transaction recovery boundary"); + } + return boundary; +} + +function parseTransactionJournal( + value: unknown, + rootPath: string, + transactionId: string, +): IOSFileTransactionJournalRecord { + if ( + !isRecord(value) || + value.schemaVersion !== 1 || + value.kind !== "clerk-ios-file-transaction" || + value.transactionId !== transactionId || + value.rootPath !== rootPath || + (value.state !== "pending" && value.state !== "committed") || + !Number.isSafeInteger(value.processId) || + (value.processId as number) <= 0 || + !Array.isArray(value.mutations) || + value.mutations.length === 0 + ) { + throw new Error("invalid iOS file transaction recovery journal"); + } + + const paths = new Set(); + const mutations: IOSFileTransactionJournalMutation[] = value.mutations.map((candidate, index) => { + if ( + !isRecord(candidate) || + (candidate.kind !== "create" && candidate.kind !== "existing") || + typeof candidate.destinationPath !== "string" || + typeof candidate.temporaryPath !== "string" || + typeof candidate.rollbackClaimPath !== "string" || + typeof candidate.recoveryDirectoryPath !== "string" || + !isRecord(candidate.recoveryDirectoryIdentity) || + !Number.isSafeInteger(candidate.recoveryDirectoryIdentity.device) || + (candidate.recoveryDirectoryIdentity.device as number) < 0 || + !Number.isSafeInteger(candidate.recoveryDirectoryIdentity.inode) || + (candidate.recoveryDirectoryIdentity.inode as number) < 0 || + !isSHA256(candidate.candidateHash) || + !Number.isInteger(candidate.mode) || + (candidate.mode as number) < 0 || + (candidate.mode as number) > 0o7777 + ) { + throw new Error("invalid iOS file transaction recovery mutation"); + } + const destinationPath = resolve(candidate.destinationPath); + const temporaryPath = resolve(candidate.temporaryPath); + const rollbackClaimPath = resolve(candidate.rollbackClaimPath); + const recoveryDirectoryPath = resolve(candidate.recoveryDirectoryPath); + const originalClaimPath = + typeof candidate.originalClaimPath === "string" + ? resolve(candidate.originalClaimPath) + : undefined; + const boundary = parseRecoveryBoundary(candidate.boundary, rootPath, destinationPath); + if ( + destinationPath !== candidate.destinationPath || + temporaryPath !== candidate.temporaryPath || + rollbackClaimPath !== candidate.rollbackClaimPath || + recoveryDirectoryPath !== candidate.recoveryDirectoryPath || + !pathIsWithin(rootPath, destinationPath) || + destinationPath === rootPath || + !isExpectedTemporaryPath(temporaryPath, destinationPath) || + recoveryDirectoryPath !== + transactionRecoveryDirectoryPath(destinationPath, transactionId, index) || + rollbackClaimPath !== resolve(recoveryDirectoryPath, "candidate") || + (candidate.kind === "existing" && + (originalClaimPath === undefined || + originalClaimPath !== resolve(recoveryDirectoryPath, "original"))) || + (candidate.kind === "create" && originalClaimPath !== undefined) || + (candidate.kind === "existing" && !isSHA256(candidate.originalHash)) || + (candidate.kind === "create" && candidate.originalHash !== undefined) + ) { + throw new Error("unsafe iOS file transaction recovery path"); + } + for (const path of [ + destinationPath, + temporaryPath, + recoveryDirectoryPath, + rollbackClaimPath, + ...(originalClaimPath ? [originalClaimPath] : []), + ]) { + if (paths.has(path)) throw new Error("duplicate iOS file transaction recovery path"); + paths.add(path); + } + return { + kind: candidate.kind, + destinationPath, + temporaryPath, + originalClaimPath, + rollbackClaimPath, + originalHash: candidate.kind === "existing" ? (candidate.originalHash as string) : undefined, + candidateHash: candidate.candidateHash, + mode: candidate.mode as number, + boundary, + recoveryDirectoryPath, + recoveryDirectoryIdentity: { + device: candidate.recoveryDirectoryIdentity.device as number, + inode: candidate.recoveryDirectoryIdentity.inode as number, + }, + }; + }); + + return { + schemaVersion: 1, + kind: "clerk-ios-file-transaction", + transactionId, + processId: value.processId as number, + rootPath, + state: value.state, + mutations, + }; +} + +async function readTransactionJournal( + path: string, + rootPath: string, + transactionId: string, +): Promise { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_JOURNAL_BYTES) { + throw new Error("invalid iOS file transaction recovery journal file"); + } + const contents = await readFile(path, "utf8"); + if (Buffer.byteLength(contents) > MAX_JOURNAL_BYTES) { + throw new Error("oversized iOS file transaction recovery journal"); + } + return parseTransactionJournal(JSON.parse(contents), rootPath, transactionId); +} + +async function recoveryBoundaryStillMatches( + mutation: IOSFileTransactionJournalMutation, +): Promise { + const { boundary } = mutation; + const current = await readCurrentMutationBoundary( + boundary.rootPath, + dirname(mutation.destinationPath), + ); + return ( + current !== undefined && + current.realRootPath === boundary.realRootPath && + current.realParentPath === boundary.realParentPath && + directoryIdentitiesMatch(current.rootIdentity, boundary.rootIdentity) && + directoryIdentitiesMatch(current.parentIdentity, boundary.parentIdentity) + ); +} + +async function assertRecoveryBoundary(mutation: IOSFileTransactionJournalMutation): Promise { + if (!(await recoveryBoundaryStillMatches(mutation))) { + throw new IOSFileTransactionOwnershipError( + "an interrupted iOS mutation boundary no longer matched its prepared directory", + ); + } + try { + const info = await lstat(mutation.recoveryDirectoryPath); + if ( + !info.isDirectory() || + info.isSymbolicLink() || + info.dev !== mutation.recoveryDirectoryIdentity.device || + info.ino !== mutation.recoveryDirectoryIdentity.inode + ) { + throw new IOSFileTransactionOwnershipError( + "an interrupted iOS recovery directory no longer matched its prepared identity", + ); + } + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error; + } +} + +async function readRecoveryFile( + path: string, + mutation?: IOSFileTransactionJournalMutation, +): Promise { + if (mutation) await assertRecoveryBoundary(mutation); + let info; + try { + info = await lstat(path); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return { kind: "absent" }; + throw error; + } + if (!info.isFile() || info.isSymbolicLink()) { + throw new IOSFileTransactionOwnershipError( + "a recovery path no longer identified a regular file", + ); + } + const value = await readRegularFileIdentityAndHash(path); + if (!value) { + throw new IOSFileTransactionOwnershipError( + "a recovery file changed while it was being identified", + ); + } + return { kind: "file", ...value }; +} + +function recoveryFileMatches( + state: RecoveryFileState, + hash: string, + mode: number, +): state is Extract { + return state.kind === "file" && state.hash === hash && state.identity.mode === mode; +} + +function recoveryFileMatchesCandidate( + state: RecoveryFileState, + temporary: RecoveryFileState, + mutation: IOSFileTransactionJournalMutation, +): state is Extract { + return ( + recoveryFileMatches(state, mutation.candidateHash, mutation.mode) && + recoveryFileMatches(temporary, mutation.candidateHash, mutation.mode) && + sameFile(state.identity, temporary.identity) + ); +} + +async function removeRecoveryFile( + path: string, + hash: string, + mode: number, + mutation: IOSFileTransactionJournalMutation, +): Promise { + const state = await readRecoveryFile(path, mutation); + if (state.kind === "absent") return; + if (!recoveryFileMatches(state, hash, mode)) { + throw new IOSFileTransactionOwnershipError( + "a recovery artifact no longer contained the transaction's file", + ); + } + await assertRecoveryBoundary(mutation); + await removeClaimedPath( + { path, present: true, identity: state.identity }, + { + expectedHash: hash, + expectedMode: mode, + }, + ); + await syncDirectoryStrict(dirname(path)); +} + +async function restoreRecoveryFile( + path: string, + destinationPath: string, + mutation: IOSFileTransactionJournalMutation, +): Promise { + const state = await readRecoveryFile(path, mutation); + if (state.kind === "absent") { + throw new IOSFileTransactionOwnershipError("a required recovery file was missing"); + } + await assertRecoveryBoundary(mutation); + await restoreClaimWithoutClobber( + { path, present: true, identity: state.identity }, + destinationPath, + ); +} + +async function claimRecoveryDestination( + destinationPath: string, + state: Extract, + hash: string, + claimPath: string, + mutation: IOSFileTransactionJournalMutation, +): Promise { + if ((await readRecoveryFile(claimPath, mutation)).kind !== "absent") { + throw new IOSFileTransactionOwnershipError("a recovery claim path was already occupied"); + } + await assertRecoveryBoundary(mutation); + const result = await claimDestination( + destinationPath, + state.identity, + hash, + claimPath, + mutation.recoveryDirectoryIdentity, + ); + if (result.status === "stale") { + throw new IOSFileTransactionOwnershipError( + "a destination changed while interrupted work was being recovered", + ); + } +} + +async function recoverPendingExisting(mutation: IOSFileTransactionJournalMutation): Promise { + const originalHash = mutation.originalHash!; + let destination = await readRecoveryFile(mutation.destinationPath, mutation); + let original = await readRecoveryFile(mutation.originalClaimPath!, mutation); + let rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + const temporary = await readRecoveryFile(mutation.temporaryPath, mutation); + + if (destination.kind === "absent") { + if (rollback.kind === "file") { + if (recoveryFileMatchesCandidate(rollback, temporary, mutation) && original.kind === "file") { + await restoreRecoveryFile(mutation.originalClaimPath!, mutation.destinationPath, mutation); + } else { + await restoreRecoveryFile(mutation.rollbackClaimPath, mutation.destinationPath, mutation); + } + } else if (original.kind === "file") { + await restoreRecoveryFile(mutation.originalClaimPath!, mutation.destinationPath, mutation); + } else { + throw new IOSFileTransactionOwnershipError( + "an interrupted replacement had no file that could restore its destination", + ); + } + destination = await readRecoveryFile(mutation.destinationPath, mutation); + original = await readRecoveryFile(mutation.originalClaimPath!, mutation); + rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + } + + if (recoveryFileMatchesCandidate(destination, temporary, mutation)) { + if (original.kind !== "file") { + throw new IOSFileTransactionOwnershipError( + "an interrupted replacement no longer had its original file", + ); + } + if (rollback.kind === "file") { + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, + ); + } + await claimRecoveryDestination( + mutation.destinationPath, + destination, + mutation.candidateHash, + mutation.rollbackClaimPath, + mutation, + ); + await restoreRecoveryFile(mutation.originalClaimPath!, mutation.destinationPath, mutation); + destination = await readRecoveryFile(mutation.destinationPath, mutation); + } + + if (destination.kind === "absent") { + throw new IOSFileTransactionOwnershipError( + "an interrupted replacement still had no public destination after recovery", + ); + } -function pathIsWithin(root: string, path: string): boolean { - const rel = relative(root, path); - return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); + original = await readRecoveryFile(mutation.originalClaimPath!, mutation); + if (original.kind === "file") { + const restoredOriginal = + destination.kind === "file" && sameFile(destination.identity, original.identity); + await removeRecoveryFile( + mutation.originalClaimPath!, + restoredOriginal ? original.hash : originalHash, + restoredOriginal ? original.identity.mode : mutation.mode, + mutation, + ); + } + rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + if (rollback.kind === "file") { + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, + ); + } + await removeRecoveryFile(mutation.temporaryPath, mutation.candidateHash, mutation.mode, mutation); } -async function readCurrentMutationBoundary( - rootPath: string, - parentPath: string, -): Promise | undefined> { - try { - const realRootPath = await realpath(rootPath); - const realParentPath = await realpath(parentPath); - if (!pathIsWithin(realRootPath, realParentPath)) return undefined; - - const rootIdentity = await readDirectoryIdentity(realRootPath); - const parentIdentity = await readDirectoryIdentity(realParentPath); - if (!rootIdentity || !parentIdentity) return undefined; +async function recoverPendingCreate(mutation: IOSFileTransactionJournalMutation): Promise { + let destination = await readRecoveryFile(mutation.destinationPath, mutation); + let rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + const temporary = await readRecoveryFile(mutation.temporaryPath, mutation); + + if (destination.kind === "absent" && rollback.kind === "file") { + if (recoveryFileMatchesCandidate(rollback, temporary, mutation)) { + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, + ); + } else { + await restoreRecoveryFile(mutation.rollbackClaimPath, mutation.destinationPath, mutation); + } + destination = await readRecoveryFile(mutation.destinationPath, mutation); + rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + } - const realRootAfterRead = await realpath(rootPath); - const realParentAfterRead = await realpath(parentPath); - const rootIdentityAfterRead = await readDirectoryIdentity(realRootAfterRead); - const parentIdentityAfterRead = await readDirectoryIdentity(realParentAfterRead); - if ( - realRootAfterRead !== realRootPath || - realParentAfterRead !== realParentPath || - !rootIdentityAfterRead || - !parentIdentityAfterRead || - !directoryIdentitiesMatch(rootIdentityAfterRead, rootIdentity) || - !directoryIdentitiesMatch(parentIdentityAfterRead, parentIdentity) - ) { - return undefined; + if (recoveryFileMatchesCandidate(destination, temporary, mutation)) { + if (rollback.kind === "file") { + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, + ); } + await claimRecoveryDestination( + mutation.destinationPath, + destination, + mutation.candidateHash, + mutation.rollbackClaimPath, + mutation, + ); + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, + ); + } - return { realRootPath, rootIdentity, realParentPath, parentIdentity }; - } catch { - return undefined; + rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + if (rollback.kind === "file") { + throw new IOSFileTransactionOwnershipError( + "an interrupted create left an unrecognized recovery claim", + ); } + await removeRecoveryFile(mutation.temporaryPath, mutation.candidateHash, mutation.mode, mutation); } -/** - * Captures the root and parent directory authorized for a prepared mutation. - * Both paths are resolved twice around the identity reads so a moving or - * replaced directory is rejected instead of being recorded inconsistently. - * - * @internal The returned evidence belongs only in hidden mutation state. - */ -export async function prepareIOSFileMutationBoundary( - rootInput: string, - mutationPathInput: string, -): Promise { - const rootPath = resolve(rootInput); - const mutationPath = resolve(mutationPathInput); - if (!pathIsWithin(rootPath, mutationPath)) return undefined; - - const current = await readCurrentMutationBoundary(rootPath, dirname(mutationPath)); - return current ? { rootPath, ...current } : undefined; -} +async function recoverCommittedMutation( + mutation: IOSFileTransactionJournalMutation, +): Promise { + const destination = await readRecoveryFile(mutation.destinationPath, mutation); + if (destination.kind === "absent") { + const temporary = await readRecoveryFile(mutation.temporaryPath, mutation); + const rollback = await readRecoveryFile(mutation.rollbackClaimPath, mutation); + const sourcePath = recoveryFileMatches(temporary, mutation.candidateHash, mutation.mode) + ? mutation.temporaryPath + : recoveryFileMatches(rollback, mutation.candidateHash, mutation.mode) + ? mutation.rollbackClaimPath + : undefined; + const source = sourcePath + ? await readRecoveryFile(sourcePath, mutation) + : { kind: "absent" as const }; + if (!sourcePath || source.kind !== "file") { + throw new IOSFileTransactionOwnershipError( + "a committed replacement had no candidate that could restore its destination", + ); + } + await assertRecoveryBoundary(mutation); + const installResult = await linkOwnedSourceWithoutClobber( + sourcePath, + source.identity, + mutation.candidateHash, + mutation.destinationPath, + ); + if (installResult === "linked") await syncDirectoryStrict(dirname(mutation.destinationPath)); + } -async function mutationBoundaryStillMatches(mutation: IOSFileMutation): Promise { - const { boundary } = mutation; - if (!pathIsWithin(boundary.rootPath, mutation.path)) return false; - const current = await readCurrentMutationBoundary(boundary.rootPath, dirname(mutation.path)); - return ( - current !== undefined && - current.realRootPath === boundary.realRootPath && - current.realParentPath === boundary.realParentPath && - directoryIdentitiesMatch(current.rootIdentity, boundary.rootIdentity) && - directoryIdentitiesMatch(current.parentIdentity, boundary.parentIdentity) + if (mutation.originalClaimPath && mutation.originalHash) { + await removeRecoveryFile( + mutation.originalClaimPath, + mutation.originalHash, + mutation.mode, + mutation, + ); + } + await removeRecoveryFile( + mutation.rollbackClaimPath, + mutation.candidateHash, + mutation.mode, + mutation, ); + await removeRecoveryFile(mutation.temporaryPath, mutation.candidateHash, mutation.mode, mutation); } -async function mutationBoundariesStillMatch( - mutations: readonly IOSFileMutation[], -): Promise { - const matches = await Promise.all( - mutations.map(async (mutation) => mutationBoundaryStillMatches(mutation)), - ); - return matches.every(Boolean); +function processIsAlive(processId: number): boolean { + try { + process.kill(processId, 0); + return true; + } catch (error) { + return !isFileSystemError(error, "ESRCH"); + } } -async function fileMatchesIdentityAndHash( - path: string, - expectedIdentity: FileIdentity, - expectedHash: string, -): Promise { - try { - const beforeRead = await readRegularFileIdentity(path); - if (!beforeRead || !identitiesMatch(beforeRead, expectedIdentity)) return false; - const matchesHash = hashIOSFileBytes(await readFile(path)) === expectedHash; - const afterRead = await readRegularFileIdentity(path); - return matchesHash && afterRead !== undefined && identitiesMatch(afterRead, expectedIdentity); - } catch { - return false; +async function recoverTransactionJournal(journal: IOSFileTransactionJournal): Promise { + const mutations = + journal.record.state === "pending" + ? [...journal.record.mutations].reverse() + : journal.record.mutations; + for (const mutation of mutations) { + if (journal.record.state === "committed") { + await recoverCommittedMutation(mutation); + } else if (mutation.kind === "create") { + await recoverPendingCreate(mutation); + } else { + await recoverPendingExisting(mutation); + } } + const directories = new Set(mutations.map((mutation) => dirname(mutation.destinationPath))); + for (const directory of directories) await syncDirectoryStrict(directory); + await removeTransactionJournal(journal); } -async function pathIsAbsent(path: string): Promise { +async function recoverIOSFileTransactionsUnlocked(rootPath: string): Promise { + let entries; try { - await lstat(path); - return false; + entries = await readdir(rootPath, { withFileTypes: true }); } catch (error) { - return isFileSystemError(error, "ENOENT"); + if (isFileSystemError(error, "ENOENT")) return; + throw error; + } + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const match = JOURNAL_NAME_PATTERN.exec(entry.name); + if (!match) continue; + const path = resolve(rootPath, entry.name); + if (activeJournalPaths.has(path)) continue; + try { + const record = await readTransactionJournal(path, rootPath, match[1]!); + if (processIsAlive(record.processId)) { + throw new IOSFileTransactionOwnershipError( + "another process still owns an iOS file transaction recovery journal", + ); + } + const journal: IOSFileTransactionJournal = { + path, + nextPath: `${path}.next`, + record, + present: true, + }; + activeJournalPaths.add(path); + try { + await recoverTransactionJournal(journal); + } finally { + activeJournalPaths.delete(path); + } + } catch (error) { + throw transactionError( + "recovery-failed", + "An interrupted iOS file transaction could not be recovered automatically.", + error, + ); + } } } -function isFileSystemError(error: unknown, code: string): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === code - ); +async function hasInterruptedIOSFileTransaction(rootPath: string): Promise { + try { + const entries = await readdir(rootPath, { withFileTypes: true }); + return entries.some((entry) => JOURNAL_NAME_PATTERN.test(entry.name)); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return false; + throw error; + } } -function transactionSiblingPath(path: string, purpose: string): string { - return resolve( - dirname(path), - `.${basename(path)}.clerk-${process.pid}-${randomUUID()}.${purpose}`, - ); +/** Recovers durable file transactions left by an interrupted CLI process. */ +export async function recoverIOSFileTransactions(rootInput: string): Promise { + const rootPath = resolve(rootInput); + const existing = recoveryByRoot.get(rootPath); + if (existing) return existing; + // Ordinary inspection, Doctor, and dry-run calls remain genuinely + // read-only. A root lock is needed only when a durable journal proves that + // interrupted work must be serialized and recovered. + if (!(await hasInterruptedIOSFileTransaction(rootPath))) return; + const lockKey = await stableRootLockKey(rootPath); + if (!lockKey) { + throw transactionError( + "recovery-failed", + "The iOS file transaction recovery root could not be identified safely.", + ); + } + const recovery = withRootLock(lockKey, async () => recoverIOSFileTransactionsUnlocked(rootPath)); + recoveryByRoot.set(rootPath, recovery); + try { + await recovery; + } finally { + if (recoveryByRoot.get(rootPath) === recovery) recoveryByRoot.delete(rootPath); + } } async function removeClaimedPath( @@ -361,6 +1577,7 @@ async function removeClaimedPath( } await rm(claim.path); claim.present = false; + await syncDirectoryStrict(dirname(claim.path)); } async function restoreClaimWithoutClobber( @@ -373,8 +1590,9 @@ async function restoreClaimWithoutClobber( if (isFileSystemError(error, "EEXIST")) { const destinationIdentity = await readPathIdentity(destinationPath); if (destinationIdentity && sameFile(destinationIdentity, claim.identity)) { + await syncDirectoryStrict(dirname(destinationPath)); await removeClaimedPath(claim); - await syncDirectory(dirname(destinationPath)); + await syncDirectoryStrict(dirname(destinationPath)); return; } } @@ -396,8 +1614,9 @@ async function restoreClaimWithoutClobber( "a restored destination no longer identified the transaction's claimed file", ); } + await syncDirectoryStrict(dirname(destinationPath)); await removeClaimedPath(claim); - await syncDirectory(dirname(destinationPath)); + await syncDirectoryStrict(dirname(destinationPath)); } type ClaimDestinationResult = @@ -413,16 +1632,49 @@ async function claimDestination( destinationPath: string, expectedIdentity: FileIdentity, expectedHash: string, + claimedPath = transactionSiblingPath(destinationPath, "claimed"), + expectedClaimParentIdentity?: DirectoryIdentity, ): Promise { - const claimedPath = transactionSiblingPath(destinationPath, "claimed"); + if (expectedClaimParentIdentity) { + const claimParentIdentity = await readDirectoryIdentity(dirname(claimedPath)); + if ( + !claimParentIdentity || + !directoryIdentitiesMatch(claimParentIdentity, expectedClaimParentIdentity) || + !(await pathIsAbsent(claimedPath)) + ) { + throw new IOSFileTransactionOwnershipError( + "the transaction-owned recovery claim was not empty and intact", + ); + } + } try { await rename(destinationPath, claimedPath); + const destinationDirectory = dirname(destinationPath); + const claimDirectory = dirname(claimedPath); + // When the names live in different directories, make the recovery name + // durable before making removal of the public name durable. A power loss + // must never preserve the deletion while losing the only recoverable link. + await syncDirectoryStrict(claimDirectory); + if (destinationDirectory !== claimDirectory) { + await syncDirectoryStrict(destinationDirectory); + } } catch (error) { if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; throw error; } const movedIdentity = await readPathIdentity(claimedPath); + if (expectedClaimParentIdentity) { + const claimParentIdentity = await readDirectoryIdentity(dirname(claimedPath)); + if ( + !claimParentIdentity || + !directoryIdentitiesMatch(claimParentIdentity, expectedClaimParentIdentity) + ) { + throw new IOSFileTransactionOwnershipError( + "the transaction-owned recovery directory changed during destination claim", + ); + } + } if (!movedIdentity) { throw new IOSFileTransactionOwnershipError( "a claimed destination could not be identified after it was moved", @@ -867,17 +2119,6 @@ async function rollbackCommitted( claim: ClaimedDestination; mutation: IOSFileMutation; }> = []; - const rollbackFiles = await stageAll( - reversed - .map((item) => item.mutation) - .filter((mutation): mutation is IOSExistingFileMutation => !isCreateMutation(mutation)), - (mutation) => { - if (isCreateMutation(mutation)) { - throw new Error("create mutations do not have original bytes"); - } - return { bytes: mutation.originalBytes, hash: mutation.originalHash }; - }, - ); try { // Restore every still-owned existing file before attempting to remove a @@ -895,7 +2136,6 @@ async function rollbackCommitted( ); } - let rollbackIndex = 0; for (const item of reversed) { if (!(await committedCandidateIsUntouched(item))) { throw transactionError( @@ -909,6 +2149,8 @@ async function rollbackCommitted( item.mutation.path, candidateIdentity, item.mutation.candidateHash, + item.rollbackClaimPath, + item.recoveryDirectory?.identity, ); if (claimResult.status === "stale") { throw transactionError( @@ -922,7 +2164,7 @@ async function rollbackCommitted( expectedHash: item.mutation.candidateHash, expectedMode: item.mutation.mode, }); - await syncDirectory(dirname(item.mutation.path)); + await syncDirectoryStrict(dirname(item.mutation.path)); if ( !(await mutationBoundaryStillMatches(item.mutation)) || !(await pathIsAbsent(item.mutation.path)) @@ -934,20 +2176,15 @@ async function rollbackCommitted( } continue; } - const rollback = rollbackFiles[rollbackIndex++]; - if (!rollback) { - throw new Error("a prepared rollback file was missing"); - } const originalBackup = item.claimedOriginal?.present ? item.claimedOriginal : undefined; - const originalSourcePath = originalBackup?.path ?? rollback.temporaryPath; + if (!originalBackup) { + throw new IOSFileTransactionOwnershipError( + "the original iOS file claim was missing during rollback", + ); + } + const originalSourcePath = originalBackup.path; const originalSource = await readRegularFileIdentityAndHash(originalSourcePath); - if ( - !originalSource || - (originalBackup && !sameFile(originalSource.identity, originalBackup.identity)) || - (!originalBackup && - (!identitiesMatch(originalSource.identity, rollback.stagedIdentity) || - originalSource.hash !== item.mutation.originalHash)) - ) { + if (!originalSource || !sameFile(originalSource.identity, originalBackup.identity)) { throw new IOSFileTransactionOwnershipError( "the original iOS file could not be identified during rollback", ); @@ -956,8 +2193,8 @@ async function rollbackCommitted( const originalSourceHash = originalSource.hash; // Rollback uses the same non-overwriting boundary as commit: preserve // the actual public inode first, then restore through an exclusive link. - await hooks.beforeRollbackDestinationClaim?.(rollback.mutation.path); - if (!(await mutationBoundaryStillMatches(rollback.mutation))) { + await hooks.beforeRollbackDestinationClaim?.(item.mutation.path); + if (!(await mutationBoundaryStillMatches(item.mutation))) { throw transactionError( "rollback-failed", "The iOS mutation boundary changed during rollback; newer filesystem state was preserved.", @@ -965,9 +2202,11 @@ async function rollbackCommitted( } const candidateIdentity = item.committedIdentity ?? item.stagedIdentity; const candidateClaimResult = await claimDestination( - rollback.mutation.path, + item.mutation.path, candidateIdentity, item.mutation.candidateHash, + item.rollbackClaimPath, + item.recoveryDirectory?.identity, ); if (candidateClaimResult.status === "stale") { throw transactionError( @@ -979,9 +2218,9 @@ async function rollbackCommitted( rollbackClaims.push({ claim: candidateClaim, mutation: item.mutation }); let sourceInstalled = false; try { - await hooks.afterRollbackDestinationClaim?.(rollback.mutation.path, candidateClaim.path); + await hooks.afterRollbackDestinationClaim?.(item.mutation.path, candidateClaim.path); if ( - !(await mutationBoundaryStillMatches(rollback.mutation)) || + !(await mutationBoundaryStillMatches(item.mutation)) || !(await fileMatchesIdentityAndHash( originalSourcePath, originalSourceIdentity, @@ -993,11 +2232,11 @@ async function rollbackCommitted( ); } await hooks.beforeRollbackDestinationInstall?.( - rollback.mutation.path, + item.mutation.path, originalSourcePath, candidateClaim.path, ); - if (!(await mutationBoundaryStillMatches(rollback.mutation))) { + if (!(await mutationBoundaryStillMatches(item.mutation))) { throw transactionError( "rollback-failed", "The iOS mutation boundary changed during rollback; newer filesystem state was preserved.", @@ -1007,7 +2246,7 @@ async function rollbackCommitted( originalSourcePath, originalSourceIdentity, originalSourceHash, - rollback.mutation.path, + item.mutation.path, ); if (installResult === "occupied") { throw transactionError( @@ -1016,19 +2255,20 @@ async function rollbackCommitted( ); } sourceInstalled = true; - await hooks.afterRollbackDestinationInstall?.(rollback.mutation.path); - if (!(await mutationBoundaryStillMatches(rollback.mutation))) { + await syncDirectoryStrict(dirname(item.mutation.path)); + await hooks.afterRollbackDestinationInstall?.(item.mutation.path); + if (!(await mutationBoundaryStillMatches(item.mutation))) { throw transactionError( "rollback-failed", "The iOS mutation boundary changed during rollback; newer filesystem state was preserved.", ); } - const restoredIdentity = await readRegularFileIdentity(rollback.mutation.path); + const restoredIdentity = await readRegularFileIdentity(item.mutation.path); if ( !restoredIdentity || !sameFile(restoredIdentity, originalSourceIdentity) || !(await fileMatchesIdentityAndHash( - rollback.mutation.path, + item.mutation.path, originalSourceIdentity, originalSourceHash, )) @@ -1041,21 +2281,19 @@ async function rollbackCommitted( expectedHash: item.mutation.candidateHash, expectedMode: item.mutation.mode, }); - if (originalBackup) { - await removeClaimedPath(originalBackup, { - expectedHash: originalSourceHash, - expectedMode: originalSourceIdentity.mode, - }); - } - await syncDirectory(dirname(rollback.mutation.path)); + await removeClaimedPath(originalBackup, { + expectedHash: originalSourceHash, + expectedMode: originalSourceIdentity.mode, + }); + await syncDirectoryStrict(dirname(item.mutation.path)); } catch (error) { if ( !sourceInstalled && - (await mutationBoundaryStillMatches(rollback.mutation)) && - (await pathIsAbsent(rollback.mutation.path)) + (await mutationBoundaryStillMatches(item.mutation)) && + (await pathIsAbsent(item.mutation.path)) ) { try { - await restoreClaimWithoutClobber(candidateClaim, rollback.mutation.path); + await restoreClaimWithoutClobber(candidateClaim, item.mutation.path); } catch (restoreError) { throw new IOSFileTransactionOwnershipError( "the claimed candidate could not be restored after rollback stopped", @@ -1066,7 +2304,6 @@ async function rollbackCommitted( throw error; } } - await cleanupStaged(rollbackFiles); } catch (error) { let candidateClaimCleanupError: unknown; const candidateClaimCleanupResults = await Promise.allSettled( @@ -1084,17 +2321,6 @@ async function rollbackCommitted( if (candidateClaimCleanupFailures.length > 0) { candidateClaimCleanupError = aggregateCause(candidateClaimCleanupFailures); } - try { - await cleanupStaged(rollbackFiles); - } catch (cleanupError) { - throw transactionError( - "rollback-failed", - "The iOS file transaction could not be rolled back or cleaned up completely.", - aggregateCause( - [error, candidateClaimCleanupError, cleanupError].filter((cause) => cause !== undefined), - ), - ); - } if (candidateClaimCleanupError) { throw transactionError( "rollback-failed", @@ -1143,6 +2369,46 @@ async function rollbackAfterFailure( } } +async function syncTransactionDirectories(staged: readonly StagedMutation[]): Promise { + const directories = new Set(staged.map((item) => dirname(item.mutation.path))); + for (const directory of directories) await syncDirectoryStrict(directory); +} + +async function rollbackDurableTransaction( + committed: readonly StagedMutation[], + staged: readonly StagedMutation[], + hooks: IOSFileTransactionTestHooks, + journal: IOSFileTransactionJournal, +): Promise { + try { + await rollbackAfterFailure(committed, staged, hooks); + await syncTransactionDirectories(staged); + try { + await removeTransactionJournal(journal); + } catch (error) { + if (isFileSystemError(error, "ENOTEMPTY")) { + activeJournalPaths.delete(journal.path); + return; + } + throw error; + } + } catch (error) { + activeJournalPaths.delete(journal.path); + if (committed.every((item) => isCreateMutation(item.mutation))) { + try { + activeJournalPaths.add(journal.path); + await recoverTransactionJournal(journal); + } catch { + // Keep the durable journal and verified artifacts for the next startup + // when immediate recovery cannot safely converge the filesystem. + } finally { + activeJournalPaths.delete(journal.path); + } + } + throw error; + } +} + async function postconditionsAreValid( postconditions: readonly IOSFilePostcondition[], ): Promise { @@ -1166,6 +2432,27 @@ export async function applyIOSFileTransaction( ): Promise { const prepared = snapshotMutations(mutations); validateMutations(prepared); + const rootPath = transactionRoot(prepared); + if (!(await mutationBoundariesStillMatch(prepared))) return { status: "stale" }; + const lockKey = await stableRootLockKey(rootPath); + if (!lockKey) return { status: "stale" }; + return withRootLock( + lockKey, + async () => { + if (!(await mutationBoundariesStillMatch(prepared))) return { status: "stale" }; + return applyPreparedIOSFileTransaction(prepared, postconditions, hooks, rootPath); + }, + hooks.beforeRootLockPublication, + ); +} + +async function applyPreparedIOSFileTransaction( + prepared: readonly IOSFileMutation[], + postconditions: readonly IOSFilePostcondition[], + hooks: IOSFileTransactionTestHooks, + rootPath: string, +): Promise { + await recoverIOSFileTransactions(rootPath); const initialIdentities = await captureInitialExistingFileIdentities(prepared); if (!initialIdentities) return { status: "stale" }; let staged: StagedMutation[]; @@ -1185,6 +2472,43 @@ export async function applyIOSFileTransaction( return { status: "stale" }; } + let journal: IOSFileTransactionJournal; + try { + journal = await createTransactionJournal( + staged, + hooks.beforeRecoveryJournalPublication, + hooks.afterInitialRecoveryJournalPublication, + ); + } catch (error) { + if (!(error instanceof IOSFileTransactionUnsafeSetupCleanupError)) { + try { + await cleanupStaged(staged); + } catch (cleanupError) { + throw transactionError( + "cleanup-failed", + "The iOS file transaction could not clean up after recovery setup failed.", + aggregateCause([error, cleanupError]), + ); + } + if (error instanceof IOSFileTransactionStaleError) return { status: "stale" }; + } + throw transactionError( + "stage-failed", + "The iOS file transaction could not prepare durable recovery state.", + error, + ); + } + try { + await hooks.afterRecoveryJournalPublished?.(journal.path); + } catch (error) { + await rollbackDurableTransaction([], staged, hooks, journal); + throw transactionError( + "commit-failed", + "The iOS file transaction stopped after preparing durable recovery state.", + error, + ); + } + let stale = false; let commitError: unknown; for (const item of staged) { @@ -1220,6 +2544,8 @@ export async function applyIOSFileTransaction( item.mutation.path, expectedIdentity, item.mutation.originalHash, + item.originalClaimPath, + item.recoveryDirectory?.identity, ); if (claimResult.status === "stale") { stale = true; @@ -1259,7 +2585,7 @@ export async function applyIOSFileTransaction( expectedHash: item.mutation.originalHash, expectedMode: item.mutation.mode, }); - await syncDirectory(dirname(item.mutation.path)); + await syncDirectoryStrict(dirname(item.mutation.path)); stale = true; break; } @@ -1284,7 +2610,7 @@ export async function applyIOSFileTransaction( throw new Error("committed file identity did not match its staged candidate"); } item.committedIdentity = committedIdentity; - await syncDirectory(dirname(item.mutation.path)); + await syncDirectoryStrict(dirname(item.mutation.path)); if (!(await mutationBoundariesStillMatch(prepared))) { throw new IOSFileTransactionStaleError(); } @@ -1318,7 +2644,7 @@ export async function applyIOSFileTransaction( } if (stale || commitError) { - await rollbackAfterFailure(committed, staged, hooks); + await rollbackDurableTransaction(committed, staged, hooks, journal); if (stale) return { status: "stale" }; throw transactionError( "commit-failed", @@ -1344,28 +2670,34 @@ export async function applyIOSFileTransaction( originalsMatchedAfterPostvalidation ) { try { + await markTransactionCommitted(journal); + await hooks.afterDurableCommit?.(journal.path); await cleanupClaimedOriginals(committed); + await cleanupStaged(staged); + await syncTransactionDirectories(staged); + await hooks.afterCommittedArtifactCleanup?.(journal.path); + await removeTransactionJournal(journal); + return { status: "applied" }; } catch (error) { - await rollbackAfterFailure(committed, staged, hooks); - if (error instanceof IOSFileTransactionOwnershipError) return { status: "stale" }; + if (journal.record.state === "pending") { + await rollbackDurableTransaction(committed, staged, hooks, journal); + if (error instanceof IOSFileTransactionOwnershipError) return { status: "stale" }; + throw transactionError( + "commit-failed", + "The iOS file transaction could not durably commit and was restored.", + error, + ); + } + activeJournalPaths.delete(journal.path); throw transactionError( "cleanup-failed", - "The iOS file transaction could not release its claimed original files and was restored.", + "The committed iOS file transaction could not finish cleaning up its recovery artifacts.", error, ); } - await cleanupStaged(staged); - if ( - (await mutationBoundariesStillMatch(prepared)) && - (await committedCandidatesAreUntouched(committed)) - ) { - return { status: "applied" }; - } - await rollbackAfterFailure(committed, staged, hooks); - return { status: "stale" }; } - await rollbackAfterFailure(committed, staged, hooks); + await rollbackDurableTransaction(committed, staged, hooks, journal); return boundariesMatchedBeforePostvalidation && candidatesMatchedBeforePostvalidation && boundariesMatchedAfterPostvalidation && diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index eb9d52fc2..f89a861a0 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -17,6 +17,7 @@ import { relativeIOSPath, xmlAttribute, } from "./discovery.ts"; +import { recoverIOSFileTransactions } from "./file-transaction.ts"; import { asString, asStringArray, @@ -1727,6 +1728,7 @@ export async function inspectIOSProject( ? dirname(dirname(invocationPath)) : dirname(invocationPath) : invocationPath; + await recoverIOSFileTransactions(root); const diagnostics: IOSDiagnostic[] = []; const discovered = await discoverIOSContainers(invocationPath, { exhaustive: options.exhaustiveContainerDiscovery === true, From b948a7a5d7733d642df4d67fa6525895b5170079 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 12:32:01 -0400 Subject: [PATCH 22/45] fix(init): keep iOS inspection read-only --- .../init/ios/file-transaction.test.ts | 17 +++++ .../src/commands/init/ios/file-transaction.ts | 17 ++++- .../src/commands/init/ios/inspect.test.ts | 70 ++++++++++++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 33 ++++++++- .../cli-core/src/commands/init/ios/types.ts | 1 + 5 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts index 60ebcede3..c0f66072d 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.test.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.test.ts @@ -17,6 +17,7 @@ import { basename, dirname, join } from "node:path"; import { applyIOSExistingFileTransaction, applyIOSFileTransaction, + hasInterruptedIOSFileTransaction, hashIOSFileBytes, IOSFileTransactionError, prepareIOSFileMutationBoundary, @@ -1184,6 +1185,22 @@ describe("iOS create-file transaction", () => { }); describe("iOS file transaction crash recovery", () => { + test("does not report the current process's active transaction as interrupted", async () => { + const root = await temporaryRoot(); + const path = join(root, "App.swift"); + await writeFile(path, "original source\n"); + const prepared = await mutation(path, "candidate source\n", root); + + const result = await applyIOSExistingFileTransaction( + [prepared], + [async () => !(await hasInterruptedIOSFileTransaction(root))], + ); + + expect(result).toEqual({ status: "applied" }); + expect(await readFile(path, "utf8")).toBe("candidate source\n"); + expect(await hasInterruptedIOSFileTransaction(root)).toBe(false); + }); + test("does not touch the project root when no interrupted transaction exists", async () => { const root = await temporaryRoot(); const before = await lstat(root, { bigint: true }); diff --git a/packages/cli-core/src/commands/init/ios/file-transaction.ts b/packages/cli-core/src/commands/init/ios/file-transaction.ts index 8bc500cd9..39badca53 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -1523,10 +1523,23 @@ async function recoverIOSFileTransactionsUnlocked(rootPath: string): Promise { +/** + * Reports whether a durable iOS file transaction still needs recovery. + * + * This check is intentionally read-only. Doctor, dry-run, and other + * inspection-only callers use it to fail closed without changing the + * checkout. A mutating `clerk init` path must call + * {@link recoverIOSFileTransactions} before it performs semantic inspection. + */ +export async function hasInterruptedIOSFileTransaction(rootInput: string): Promise { + const rootPath = resolve(rootInput); try { const entries = await readdir(rootPath, { withFileTypes: true }); - return entries.some((entry) => JOURNAL_NAME_PATTERN.test(entry.name)); + return entries.some( + (entry) => + JOURNAL_NAME_PATTERN.test(entry.name) && + !activeJournalPaths.has(resolve(rootPath, entry.name)), + ); } catch (error) { if (isFileSystemError(error, "ENOENT")) return false; throw error; diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index addbdabb2..0f651f7cb 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1,14 +1,16 @@ import { afterEach, describe, expect, test } from "bun:test"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; -import { mkdtemp, mkdir, rm, symlink } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, readFile, rm, symlink } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import { recoverIOSFileTransactions } from "./file-transaction.ts"; import type { PbxObjects } from "./pbx.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; +const FILE_TRANSACTION_MODULE = `${import.meta.dir}/file-transaction.ts`; async function fixture(options: Parameters[1] = {}): Promise { const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); @@ -17,6 +19,42 @@ async function fixture(options: Parameters[1] = {}): Pr return root; } +async function interruptProjectFileTransaction(root: string, path: string): Promise { + const source = ` + const { readFile, lstat } = await import("node:fs/promises"); + const { + applyIOSExistingFileTransaction, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + } = await import(${JSON.stringify(FILE_TRANSACTION_MODULE)}); + const root = ${JSON.stringify(root)}; + const path = ${JSON.stringify(path)}; + const originalBytes = new Uint8Array(await readFile(path)); + const candidateBytes = new TextEncoder().encode("candidate project bytes\\n"); + const boundary = await prepareIOSFileMutationBoundary(root, path); + const info = await lstat(path); + await applyIOSExistingFileTransaction([ + { + path, + boundary, + originalBytes, + originalHash: hashIOSFileBytes(originalBytes), + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: info.mode & 0o7777, + }, + ], [async () => true], { + afterExistingDestinationClaim: () => process.kill(process.pid, "SIGKILL"), + }); + `; + const child = Bun.spawn([process.execPath, "-e", source], { + stdout: "ignore", + stderr: "ignore", + }); + await child.exited; + expect(child.signalCode).toBe("SIGKILL"); +} + async function transformProject( root: string, transform: (objects: PbxObjects) => void, @@ -159,6 +197,36 @@ describe("discoverIOSContainers", () => { }); describe("inspectIOSProject", () => { + test("reports interrupted file transactions without recovering or changing project bytes", async () => { + const root = await fixture({ complete: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const originalProject = await readFile(projectPath); + await interruptProjectFileTransaction(root, projectPath); + + await expect(lstat(projectPath)).rejects.toMatchObject({ code: "ENOENT" }); + const beforeInspection = await treeDigest(root); + const inspection = await inspectIOSProject(root); + + expect(inspection).toMatchObject({ + selection: { state: "none" }, + diagnostics: [ + { + code: "xcode.interrupted-file-transaction", + severity: "error", + }, + ], + }); + expect(await treeDigest(root)).toEqual(beforeInspection); + await expect(lstat(projectPath)).rejects.toMatchObject({ code: "ENOENT" }); + + await recoverIOSFileTransactions(root); + expect(Buffer.from(await readFile(projectPath))).toEqual(Buffer.from(originalProject)); + expect((await inspectIOSProject(root)).selection).toMatchObject({ + state: "selected", + targetName: "MyApp", + }); + }, 15_000); + test("fails source ownership closed when a project-container link is skipped", async () => { const root = await fixture({ complete: true }); const project = join(root, "MyApp.xcodeproj"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index f89a861a0..ac0a7d93b 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -17,7 +17,7 @@ import { relativeIOSPath, xmlAttribute, } from "./discovery.ts"; -import { recoverIOSFileTransactions } from "./file-transaction.ts"; +import { hasInterruptedIOSFileTransaction } from "./file-transaction.ts"; import { asString, asStringArray, @@ -1728,7 +1728,36 @@ export async function inspectIOSProject( ? dirname(dirname(invocationPath)) : dirname(invocationPath) : invocationPath; - await recoverIOSFileTransactions(root); + if (await hasInterruptedIOSFileTransaction(root)) { + return { + schemaVersion: 1, + platform: "ios", + root, + workspaces: [], + projects: [], + appTargets: [], + selection: { state: "none" }, + localPublishableKey: { + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }, + generatedProject: null, + diagnostics: [ + { + code: "xcode.interrupted-file-transaction", + severity: "error", + message: + "Clerk stopped inspection because an iOS file update is incomplete or still active.", + remedy: + "Wait for any running Clerk command to finish. If none is running, run `clerk init` without `--dry-run` to recover the interrupted update before inspecting the project again.", + evidence: [], + }, + ], + }; + } const diagnostics: IOSDiagnostic[] = []; const discovered = await discoverIOSContainers(invocationPath, { exhaustive: options.exhaustiveContainerDiscovery === true, diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 2e0e7624d..839abb02d 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -25,6 +25,7 @@ export interface IOSDiagnostic { | "xcode.generated-project" | "xcode.incomplete-source-membership" | "xcode.incomplete-scheme-discovery" + | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key" | "clerk.conflicting-publishable-keys"; From 7bc75cd7c1317d4ada4f3e339002576bf518ca7b Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 15:35:07 -0400 Subject: [PATCH 23/45] fix(init): require proven iOS key wiring --- .../src/commands/init/ios/inspect.test.ts | 35 ++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 46 +++++++++++++++++++ .../src/commands/init/ios/products.test.ts | 41 ++++++++++++++--- .../src/commands/init/ios/products.ts | 26 +++++++---- .../cli-core/src/commands/init/ios/types.ts | 3 +- 5 files changed, 135 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 0f651f7cb..d8031c85d 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -772,6 +772,14 @@ describe("inspectIOSProject", () => { expect(inspection.appTargets[0]?.runtimeKeySinks).toEqual([ { kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }, ]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + message: expect.stringContaining("LocalSecrets.plist"), + evidence: [{ path: "MyApp/LocalSecrets.plist", keyPath: "CLERK_PUBLISHABLE_KEY" }], + }), + ); expect(JSON.stringify(inspection)).not.toContain("pk_live_"); }); @@ -881,6 +889,19 @@ struct MyApp: App { frontendApiHost: "scheme.clerk.example", conflict: false, }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + message: expect.stringContaining("Run-scheme key"), + evidence: [ + { + path: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + keyPath: "CLERK_PUBLISHABLE_KEY", + }, + ], + }), + ); expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); @@ -1152,6 +1173,13 @@ struct MyApp: App { (diagnostic) => diagnostic.code === "xcode.incomplete-scheme-discovery", ), ).toBe(false); + expect( + inspection.diagnostics.some( + (diagnostic) => + diagnostic.code === "clerk.unconsumed-publishable-key-source" && + diagnostic.message.includes("LocalSecrets.plist"), + ), + ).toBe(false); expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); }); @@ -1270,6 +1298,13 @@ struct MyApp: App { ], invalidSources: [], }); + expect( + inspection.diagnostics.some( + (diagnostic) => + diagnostic.code === "clerk.unconsumed-publishable-key-source" && + diagnostic.message.includes("Run-scheme key"), + ), + ).toBe(false); expect(JSON.stringify(inspection)).not.toContain(schemeKey); expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); }); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index ac0a7d93b..a83c40ff6 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -31,6 +31,7 @@ import { type PbxObjects, } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import { hasIOSProvenStartupKeyWiring } from "./products.ts"; import { inspectSwiftSources } from "./swift.ts"; import type { IOSAppTarget, @@ -649,6 +650,50 @@ function preferredRuntimeKeyCandidateKind( return undefined; } +function addUnconsumedRuntimeKeyDiagnostics( + target: IOSAppTarget | undefined, + localPublishableKey: IOSProjectInspectionResult["localPublishableKey"], + diagnostics: IOSDiagnostic[], +): void { + if (!target) return; + + if ( + target.runtimeKeySinks.length > 0 && + !hasIOSProvenStartupKeyWiring(target, "local-secrets-loader") + ) { + diagnostics.push({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + message: + "Clerk found a target-owned LocalSecrets.plist but could not prove that the selected app consumes it at startup.", + remedy: + "Clerk will leave this file unchanged. Connect it from Clerk.configure in the selected @main app's init(), or remove it if it is stale.", + evidence: target.runtimeKeySinks.map((sink) => ({ + path: sink.path, + keyPath: "CLERK_PUBLISHABLE_KEY", + })), + }); + } + + const schemeSources = localPublishableKey.candidateSources.filter((source) => + source.endsWith(".xcscheme"), + ); + if ( + schemeSources.length > 0 && + !hasIOSProvenStartupKeyWiring(target, "process-info-environment") + ) { + diagnostics.push({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + message: + "Clerk found a selected-target Run-scheme key but could not prove that the selected app consumes it at startup.", + remedy: + "Clerk will leave this scheme unchanged. Read CLERK_PUBLISHABLE_KEY from ProcessInfo in Clerk.configure in the selected @main app's init(), or remove the variable if it is stale.", + evidence: schemeSources.map((path) => ({ path, keyPath: "CLERK_PUBLISHABLE_KEY" })), + }); + } +} + async function localPackageIsClerk(root: string, packagePath: string): Promise { const manifestPath = resolve(packagePath, "Package.swift"); if (!(await pathIsSafelyWithinIOSRoot(root, manifestPath))) return false; @@ -1857,6 +1902,7 @@ export async function inspectIOSProject( preferredRuntimeKeyCandidateKind(selectedAppTarget), diagnostics, ); + addUnconsumedRuntimeKeyDiagnostics(selectedAppTarget, localPublishableKeyInspection, diagnostics); const result: IOSProjectInspectionResult = { schemaVersion: 1, platform: "ios", diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index 76bff763c..e3f208a4b 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -102,7 +102,7 @@ describe("shouldInstallClerkKitUI", () => { describe("direct configuration compatibility", () => { test.each(["local-secrets-loader", "process-info-environment"] as const)( - "preserves an existing %s configure route", + "preserves a proven startup %s configure route", (publishableKeyWiring) => { const selected = target(); selected.swift.configureCalls = [ @@ -122,24 +122,51 @@ describe("direct configuration compatibility", () => { }, ); - test("preserves an enabled selected-target scheme key route", () => { + test("does not infer compatibility from an enabled selected-target scheme key", () => { const selected = target(); const result = inspection(selected); result.localPublishableKey.candidateSources = [ "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", ]; - expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(true); - expect(shouldPlanIOSDirectConfig(result, selected)).toBe(false); + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); }); - test("preserves a target-owned runtime key sink", () => { + test("does not infer compatibility from a target-owned runtime key sink", () => { const selected = target(); selected.runtimeKeySinks = [{ kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }]; const result = inspection(selected); - expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(true); - expect(shouldPlanIOSDirectConfig(result, selected)).toBe(false); + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); + }); + + test.each([ + { + name: "non-startup ProcessInfo", + publishableKeyWiring: "process-info-environment" as const, + startupBinding: "unproven" as const, + }, + { + name: "non-startup LocalSecrets", + publishableKeyWiring: "local-secrets-loader" as const, + startupBinding: "unproven" as const, + localSecretsRuntimeBinding: "proven" as const, + }, + { + name: "unproven LocalSecrets loader", + publishableKeyWiring: "local-secrets-loader" as const, + startupBinding: "app-init" as const, + localSecretsRuntimeBinding: "unproven" as const, + }, + ])("does not preserve $name wiring", (configureCall) => { + const selected = target(); + selected.swift.configureCalls = [{ path: "MyApp/Auth.swift", ...configureCall }]; + const result = inspection(selected); + + expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); + expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); }); test("plans direct configuration for a fresh compatible target", () => { diff --git a/packages/cli-core/src/commands/init/ios/products.ts b/packages/cli-core/src/commands/init/ios/products.ts index 86b9e3ddd..449f7f5dc 100644 --- a/packages/cli-core/src/commands/init/ios/products.ts +++ b/packages/cli-core/src/commands/init/ios/products.ts @@ -22,20 +22,30 @@ export function shouldInstallClerkKitUI(target: IOSAppTarget): boolean { return clerkKitUIInstallDecision(target) === "prebuilt"; } +export function hasIOSProvenStartupKeyWiring( + target: IOSAppTarget, + wiring: "local-secrets-loader" | "process-info-environment", +): boolean { + return target.swift.configureCalls.some( + (call) => + call.startupBinding === "app-init" && + call.publishableKeyWiring === wiring && + (wiring !== "local-secrets-loader" || call.localSecretsRuntimeBinding === "proven"), + ); +} + /** Existing runtime-key routes that direct source configuration must preserve. */ export function hasIOSDirectConfigCompatibility( inspection: IOSProjectInspectionResult, target: IOSAppTarget, ): boolean { - const hasCompatibleConfigure = target.swift.configureCalls.some( - (call) => - call.publishableKeyWiring === "local-secrets-loader" || - call.publishableKeyWiring === "process-info-environment", - ); - const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => - source.endsWith(".xcscheme"), + // Preserve the inspection parameter for callers that make this decision + // from a complete inspection, but never infer runtime wiring from artifacts. + void inspection; + return ( + hasIOSProvenStartupKeyWiring(target, "local-secrets-loader") || + hasIOSProvenStartupKeyWiring(target, "process-info-environment") ); - return hasCompatibleConfigure || hasEnabledSchemeKey || target.runtimeKeySinks.length > 0; } /** diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 839abb02d..85e8de808 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -28,7 +28,8 @@ export interface IOSDiagnostic { | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key" - | "clerk.conflicting-publishable-keys"; + | "clerk.conflicting-publishable-keys" + | "clerk.unconsumed-publishable-key-source"; severity: IOSDiagnosticSeverity; message: string; remedy?: string; From 9d177c9a2e77ed524731749049430089b24d17ff Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 16:43:08 -0400 Subject: [PATCH 24/45] test: preload CLI version macro --- bunfig.toml | 3 +++ packages/cli-core/src/test/version-preload.ts | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 packages/cli-core/src/test/version-preload.ts diff --git a/bunfig.toml b/bunfig.toml index b239cb499..ed67b3d62 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,6 @@ [install] # Only install package versions published at least 2 days ago minimumReleaseAge = 172800 + +[test] +preload = ["./packages/cli-core/src/test/version-preload.ts"] diff --git a/packages/cli-core/src/test/version-preload.ts b/packages/cli-core/src/test/version-preload.ts new file mode 100644 index 000000000..b8651b122 --- /dev/null +++ b/packages/cli-core/src/test/version-preload.ts @@ -0,0 +1,4 @@ +// Bun macro imports can be order-dependent when a test first reaches the CLI +// through a deeply mocked command graph. Load the real version module before +// test files so every isolated worker resolves its macro deterministically. +import "../lib/version.ts"; From 83afb85a420eaba94345693ae23c8531f8456e6c Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:21:10 -0400 Subject: [PATCH 25/45] fix(init): strengthen iOS inspection evidence --- .../src/commands/init/ios/inspect.test.ts | 245 +++++++++++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 137 +++++++--- .../src/commands/init/ios/local-package.ts | 128 +++++++++ .../cli-core/src/commands/init/ios/types.ts | 3 +- 4 files changed, 474 insertions(+), 39 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/local-package.ts diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index d8031c85d..760b3f899 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -66,6 +66,46 @@ async function transformProject( await Bun.write(projectPath, buildPbxProject(project)); } +async function addSynchronizedTargetRoot(root: string, path = "Synced"): Promise { + const groupId = "474747474747474747474747"; + await transformProject(root, (objects) => { + objects[groupId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path, + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [groupId]; + }); +} + +function localSecretsPlist(host: string): string { + const encodedHost = Buffer.from(`${host}$`).toString("base64"); + return `CLERK_PUBLISHABLE_KEYpk_test_${encodedHost}`; +} + +async function writeStructurallyValidLocalClerkPackage(root: string): Promise { + const packageRoot = join(root, "LocalClerk"); + await mkdir(join(packageRoot, "Sources", "ClerkKit"), { recursive: true }); + await mkdir(join(packageRoot, "Sources", "ClerkKitUI"), { recursive: true }); + await Bun.write( + join(packageRoot, "Package.swift"), + `// swift-tools-version: 6.0 +import PackageDescription +let package = Package( + name: "Clerk", + products: [ + .library(name: "ClerkKit", targets: ["ClerkKit"]), + .library(name: "ClerkKitUI", targets: ["ClerkKitUI"]), + ], + targets: [ + .target(name: "ClerkKit"), + .target(name: "ClerkKitUI"), + ] +) +`, + ); +} + async function setAssociatedDomainTemplate(root: string, template: string): Promise { const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); const entitlements = await Bun.file(entitlementsPath).text(); @@ -382,11 +422,7 @@ describe("inspectIOSProject", () => { async (kind) => { const root = await fixture({ clerkSDK: "core-only" }); if (kind === "local") { - await mkdir(join(root, "LocalClerk")); - await Bun.write( - join(root, "LocalClerk", "Package.swift"), - '// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "Clerk", products: [.library(name: "ClerkKit", targets: ["ClerkKit"])], targets: [.target(name: "ClerkKit")])\n', - ); + await writeStructurallyValidLocalClerkPackage(root); } await transformProject(root, (objects) => { delete objects[IOS_FIXTURE_IDS.clerkKit]!.package; @@ -408,6 +444,64 @@ describe("inspectIOSProject", () => { }, ); + test.each([ + { + name: "comment", + manifest: `// swift-tools-version: 6.0 +import PackageDescription +// Package(name: "Clerk", products: [.library(name: "ClerkKit", targets: ["ClerkKit"]), .library(name: "ClerkKitUI", targets: ["ClerkKitUI"])], targets: [.target(name: "ClerkKit"), .target(name: "ClerkKitUI")]) +let package = Package(name: "Other") +`, + }, + { + name: "dependency", + manifest: `// swift-tools-version: 6.0 +import PackageDescription +let package = Package( + name: "Other", + dependencies: [.package(url: "https://github.com/clerk/clerk-ios", from: "1.0.0")], + targets: [.target(name: "Other", dependencies: [.product(name: "ClerkKit", package: "clerk-ios"), .product(name: "ClerkKitUI", package: "clerk-ios")])] +) +`, + }, + { + name: "product-only declaration", + manifest: `// swift-tools-version: 6.0 +import PackageDescription +let package = Package( + name: "Clerk", + products: [ + .library(name: "ClerkKit", targets: ["ClerkKit"]), + .library(name: "ClerkKitUI", targets: ["ClerkKitUI"]), + ], + targets: [.target(name: "Other")] +) +`, + }, + ])("does not attribute a local package from a $name decoy", async ({ manifest }) => { + const root = await fixture({ clerkSDK: "core-only" }); + await mkdir(join(root, "LocalClerk", "Sources", "ClerkKit"), { recursive: true }); + await mkdir(join(root, "LocalClerk", "Sources", "ClerkKitUI"), { recursive: true }); + await Bun.write(join(root, "LocalClerk", "Package.swift"), manifest); + await transformProject(root, (objects) => { + objects[IOS_FIXTURE_IDS.clerkPackage] = { + isa: "XCLocalSwiftPackageReference", + relativePath: "LocalClerk", + }; + }); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "unattributed", + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "clerk.package-unattributed" }), + ); + }); + test("preserves absent, exact, and invalid Apple entitlement states", async () => { const cases = [ { @@ -783,6 +877,147 @@ describe("inspectIOSProject", () => { expect(JSON.stringify(inspection)).not.toContain("pk_live_"); }); + test("reads LocalSecrets.plist from a completely discovered synchronized target root", async () => { + const root = await fixture({ complete: true, includeKey: false }); + await addSynchronizedTargetRoot(root); + await mkdir(join(root, "Synced", "Configuration"), { recursive: true }); + await Bun.write( + join(root, "Synced", "Configuration", "LocalSecrets.plist"), + localSecretsPlist("synchronized.clerk.example"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toMatchObject({ + evidenceComplete: true, + found: true, + source: "Synced/Configuration/LocalSecrets.plist", + frontendApiHost: "synchronized.clerk.example", + }); + }); + + test("fails closed when a discovered LocalSecrets.plist is followed by depth-truncated evidence", async () => { + const root = await fixture({ complete: true, includeKey: false }); + await addSynchronizedTargetRoot(root); + await mkdir(join(root, "Synced", "00-visible"), { recursive: true }); + await Bun.write( + join(root, "Synced", "00-visible", "LocalSecrets.plist"), + localSecretsPlist("visible.clerk.example"), + ); + const hiddenDirectory = join( + root, + "Synced", + "10-deep", + "level-1", + "level-2", + "level-3", + "level-4", + "level-5", + ); + await mkdir(hiddenDirectory, { recursive: true }); + await Bun.write( + join(hiddenDirectory, "LocalSecrets.plist"), + localSecretsPlist("hidden.clerk.example"), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: ["Synced/00-visible/LocalSecrets.plist"], + invalidSources: [], + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-local-secrets-discovery", + message: expect.stringContaining("MyApp"), + }), + ); + expect(inspection.localPublishableKey).not.toHaveProperty("source"); + expect(inspection.localPublishableKey).not.toHaveProperty("frontendApiHost"); + }); + + test("fails closed when synchronized LocalSecrets discovery exceeds its file bound", async () => { + const root = await fixture({ complete: true, includeKey: false }); + await addSynchronizedTargetRoot(root); + for (let index = 0; index < 21; index += 1) { + const directory = join(root, "Synced", String(index).padStart(2, "0")); + await mkdir(directory, { recursive: true }); + await Bun.write( + join(directory, "LocalSecrets.plist"), + localSecretsPlist(`bounded-${index}.clerk.example`), + ); + } + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey.evidenceComplete).toBe(false); + expect(inspection.localPublishableKey.found).toBe(false); + expect(inspection.localPublishableKey.candidateSources).toHaveLength(20); + expect(inspection.localPublishableKey).not.toHaveProperty("source"); + expect(inspection.localPublishableKey).not.toHaveProperty("frontendApiHost"); + }); + + test("fails closed when synchronized LocalSecrets discovery skips a symlink", async () => { + if (process.platform === "win32") return; + const root = await fixture({ complete: true, includeKey: false }); + await addSynchronizedTargetRoot(root); + const externalRoot = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-")); + temporaryDirectories.push(externalRoot); + await Bun.write( + join(externalRoot, "LocalSecrets.plist"), + localSecretsPlist("external.clerk.example"), + ); + await mkdir(join(root, "Synced"), { recursive: true }); + await symlink(externalRoot, join(root, "Synced", "LinkedSecrets")); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-local-secrets-discovery", + evidence: [{ path: "Synced/LinkedSecrets" }], + }), + ); + }); + + test("fails closed when a target-owned LocalSecrets.plist resolves outside the project", async () => { + if (process.platform === "win32") return; + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const externalRoot = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-")); + temporaryDirectories.push(externalRoot); + const externalPath = join(externalRoot, "LocalSecrets.plist"); + await Bun.write(externalPath, localSecretsPlist("external.clerk.example")); + await rm(join(root, "MyApp", "LocalSecrets.plist")); + await symlink(externalPath, join(root, "MyApp", "LocalSecrets.plist")); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ + evidenceComplete: false, + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }); + expect(inspection.appTargets[0]?.runtimeKeySinks).toEqual([]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-local-secrets-discovery", + evidence: [{ path: "MyApp/LocalSecrets.plist" }], + }), + ); + }); + test("treats a direct @main literal as the selected target's runtime key without exposing it", async () => { const root = await fixture({ includeKey: false, localSecrets: true }); const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index a83c40ff6..679005d0b 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -18,6 +18,7 @@ import { xmlAttribute, } from "./discovery.ts"; import { hasInterruptedIOSFileTransaction } from "./file-transaction.ts"; +import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; import { asString, asStringArray, @@ -89,6 +90,14 @@ const sourceMembershipByInspection = new WeakMap< IOSTargetSourceMembership[] >(); +interface LocalSecretsDiscovery { + paths: string[]; + complete: boolean; + evidence: IOSSourceEvidence[]; +} + +const localSecretsDiscoveryByTarget = new WeakMap(); + function emptySwiftInspection() { return { sourceFilesScanned: 0, @@ -336,30 +345,60 @@ async function collectLocalSecretsPlists( root: string, directory: string, output: string[], + state: { complete: boolean; incompletePaths: Set }, depth = 0, ): Promise { - if (depth > MAX_SECRET_DISCOVERY_DEPTH || output.length >= MAX_SECRET_FILES) return; + if ( + depth > MAX_SECRET_DISCOVERY_DEPTH || + output.length >= MAX_SECRET_FILES || + !(await pathIsSafelyWithinIOSRoot(root, directory)) + ) { + state.complete = false; + state.incompletePaths.add(directory); + return; + } let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch { + state.complete = false; + state.incompletePaths.add(directory); return; } entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { - if (output.length >= MAX_SECRET_FILES) return; const absolutePath = resolve(directory, entry.name); + if (output.length >= MAX_SECRET_FILES) { + const couldHideLocalSecrets = + (entry.isDirectory() || entry.isSymbolicLink()) && + !SOURCE_IGNORES.has(entry.name) && + !entry.name.startsWith("."); + if (couldHideLocalSecrets || (entry.isFile() && entry.name === "LocalSecrets.plist")) { + state.complete = false; + state.incompletePaths.add(absolutePath); + return; + } + continue; + } + if (entry.isSymbolicLink()) { + if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { + state.complete = false; + state.incompletePaths.add(absolutePath); + } + continue; + } if (entry.isDirectory()) { if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { - await collectLocalSecretsPlists(root, absolutePath, output, depth + 1); + await collectLocalSecretsPlists(root, absolutePath, output, state, depth + 1); + } + } else if (entry.isFile() && entry.name === "LocalSecrets.plist") { + if (await pathIsSafelyWithinIOSRoot(root, absolutePath)) { + output.push(absolutePath); + } else { + state.complete = false; + state.incompletePaths.add(absolutePath); } - } else if ( - entry.isFile() && - entry.name === "LocalSecrets.plist" && - (await pathIsSafelyWithinIOSRoot(root, absolutePath)) - ) { - output.push(absolutePath); } } } @@ -484,6 +523,8 @@ async function inspectLocalPublishableKeys( root: string, selection: IOSTargetSelection, targetLocalSecretsPaths: string[], + localSecretsDiscoveryComplete: boolean, + localSecretsDiscoveryEvidence: IOSSourceEvidence[], schemeRoots: string[], containerDiscoveryComplete: boolean, inlineCandidates: PublishableKeyCandidate[], @@ -535,6 +576,10 @@ async function inspectLocalPublishableKeys( (!schemeDiscoveryComplete || !containerDiscoveryComplete) && preferredKind !== "inline-literal" && preferredKind !== "local-secrets-plist"; + const localSecretsCouldBeEffective = + !localSecretsDiscoveryComplete && + preferredKind !== "inline-literal" && + preferredKind !== "run-scheme"; if (runSchemeCouldBeEffective) { diagnostics.push({ code: "xcode.incomplete-scheme-discovery", @@ -548,6 +593,19 @@ async function inspectLocalPublishableKeys( ...schemeDiscoveryEvidence, ], }); + } + if (localSecretsCouldBeEffective) { + const targetDescription = selection.state === "selected" ? ` for ${selection.targetName}` : ""; + diagnostics.push({ + code: "xcode.incomplete-local-secrets-discovery", + severity: "warning", + message: `LocalSecrets.plist discovery was incomplete${targetDescription}, so Clerk could not prove the selected target's runtime publishable key.`, + remedy: + "Make the selected target's synchronized folders readable, remove unsafe symlinks, or reduce excessive LocalSecrets.plist nesting or count, and rerun the command.", + evidence: localSecretsDiscoveryEvidence, + }); + } + if (runSchemeCouldBeEffective || localSecretsCouldBeEffective) { return { evidenceComplete: false, found: false, @@ -694,19 +752,6 @@ function addUnconsumedRuntimeKeyDiagnostics( } } -async function localPackageIsClerk(root: string, packagePath: string): Promise { - const manifestPath = resolve(packagePath, "Package.swift"); - if (!(await pathIsSafelyWithinIOSRoot(root, manifestPath))) return false; - const manifest = Bun.file(manifestPath); - if (!(await manifest.exists()) || manifest.size > 1_000_000) return false; - try { - const source = await manifest.text(); - return /\b(?:ClerkKit|ClerkKitUI)\b/.test(source); - } catch { - return false; - } -} - async function inspectPackageReferences( root: string, projectPath: string, @@ -748,7 +793,7 @@ async function inspectPackageReferences( kind: "local", objectId, path: safelyLocal ? relativeIOSPath(root, absolutePath) : absolutePath, - isClerk: safelyLocal && (await localPackageIsClerk(root, absolutePath)), + isClerk: safelyLocal && (await localClerkIOSPackageIsStructurallyValid(root, absolutePath)), }); } } @@ -1111,11 +1156,17 @@ async function localSecretsForTarget(options: { targetObject: PbxObject; objects: PbxObjects; parents: Map; -}): Promise { +}): Promise { const { root, projectPath, groupRootDirectory, targetId, targetObject, objects, parents } = options; const projectDirectory = dirname(projectPath); const paths = new Set(); + const state = { complete: true, incompletePaths: new Set() }; + const graphEvidence: IOSSourceEvidence[] = []; + const projectFileEvidence = (objectId: string): IOSSourceEvidence => ({ + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId, + }); const resourcePhaseIds = new Set( asStringArray(targetObject.buildPhases).filter( (phaseId) => objects[phaseId]?.isa === "PBXResourcesBuildPhase", @@ -1137,11 +1188,13 @@ async function localSecretsForTarget(options: { projectDirectory, groupRootDirectory, ); - if ( - absolutePath?.endsWith(`${sep}LocalSecrets.plist`) && - (await pathIsSafelyWithinIOSRoot(root, absolutePath)) - ) { - paths.add(absolutePath); + if (absolutePath?.endsWith(`${sep}LocalSecrets.plist`)) { + if (await pathIsSafelyWithinIOSRoot(root, absolutePath)) { + paths.add(absolutePath); + } else { + state.complete = false; + state.incompletePaths.add(absolutePath); + } } } } @@ -1156,10 +1209,15 @@ async function localSecretsForTarget(options: { projectDirectory, groupRootDirectory, ); - if (!groupPath || !(await pathIsSafelyWithinIOSRoot(root, groupPath))) continue; + if (!groupPath || !(await pathIsSafelyWithinIOSRoot(root, groupPath))) { + state.complete = false; + if (groupPath) state.incompletePaths.add(groupPath); + else graphEvidence.push(projectFileEvidence(groupId)); + continue; + } const discovered: string[] = []; - await collectLocalSecretsPlists(root, groupPath, discovered); + await collectLocalSecretsPlists(root, groupPath, discovered, state); const excluded = synchronizedExclusions(group, targetId, resourcePhaseIds, objects); for (const absolutePath of discovered) { const relativePath = relative(groupPath, absolutePath).split(sep).join("/"); @@ -1167,7 +1225,14 @@ async function localSecretsForTarget(options: { } } - return [...paths].sort(); + return { + paths: [...paths].sort(), + complete: state.complete, + evidence: [ + ...[...state.incompletePaths].sort().map((path) => ({ path: relativeIOSPath(root, path) })), + ...graphEvidence, + ], + }; } async function collectSwiftFiles( @@ -1633,11 +1698,12 @@ async function parseProject( diagnostics, ), swift: swiftInspection, - runtimeKeySinks: targetLocalSecrets.map((path) => ({ + runtimeKeySinks: targetLocalSecrets.paths.map((path) => ({ kind: "local-secrets-plist" as const, path: relativeIOSPath(root, path), })), }; + localSecretsDiscoveryByTarget.set(appTarget, targetLocalSecrets); appTargets.push(appTarget); } @@ -1889,10 +1955,15 @@ export async function inspectIOSProject( } : { invalid: true as const }), })) ?? []; + const selectedLocalSecretsDiscovery = selectedAppTarget + ? localSecretsDiscoveryByTarget.get(selectedAppTarget) + : undefined; const localPublishableKeyInspection = await inspectLocalPublishableKeys( root, selection, selectedAppTarget?.runtimeKeySinks.map((sink) => resolve(root, sink.path)) ?? [], + selectedLocalSecretsDiscovery?.complete ?? true, + selectedLocalSecretsDiscovery?.evidence ?? [], [ ...(selection.state === "selected" ? [resolve(root, selection.projectPath)] : []), ...discovered.workspacePaths, diff --git a/packages/cli-core/src/commands/init/ios/local-package.ts b/packages/cli-core/src/commands/init/ios/local-package.ts new file mode 100644 index 000000000..9a5022957 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/local-package.ts @@ -0,0 +1,128 @@ +import { lstat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathIsSafelyWithinIOSRoot } from "./discovery.ts"; + +const MAX_PACKAGE_MANIFEST_BYTES = 1_000_000; +const CLERK_PRODUCTS = ["ClerkKit", "ClerkKitUI"] as const; + +function swiftManifestWithoutComments(source: string): string { + const characters = source.split(""); + const blank = (start: number, end: number) => { + for (let index = start; index < end; index += 1) { + if (characters[index] !== "\n" && characters[index] !== "\r") characters[index] = " "; + } + }; + + let index = 0; + while (index < characters.length) { + if (characters[index] === "/" && characters[index + 1] === "/") { + const start = index; + index += 2; + while (index < characters.length && characters[index] !== "\n") index += 1; + blank(start, index); + continue; + } + if (characters[index] === "/" && characters[index + 1] === "*") { + const start = index; + let depth = 1; + index += 2; + while (index < characters.length && depth > 0) { + if (characters[index] === "/" && characters[index + 1] === "*") { + depth += 1; + index += 2; + } else if (characters[index] === "*" && characters[index + 1] === "/") { + depth -= 1; + index += 2; + } else { + index += 1; + } + } + blank(start, index); + continue; + } + + let hashCount = 0; + while (characters[index + hashCount] === "#") hashCount += 1; + const quoteIndex = index + hashCount; + if (characters[quoteIndex] !== '"') { + index += 1; + continue; + } + const multiline = characters[quoteIndex + 1] === '"' && characters[quoteIndex + 2] === '"'; + index = quoteIndex + (multiline ? 3 : 1); + while (index < characters.length) { + const closesQuote = multiline + ? characters[index] === '"' && + characters[index + 1] === '"' && + characters[index + 2] === '"' + : characters[index] === '"'; + if (closesQuote) { + const quoteLength = multiline ? 3 : 1; + let closesHashes = true; + for (let hash = 0; hash < hashCount; hash += 1) { + if (characters[index + quoteLength + hash] !== "#") closesHashes = false; + } + if (closesHashes) { + index += quoteLength + hashCount; + break; + } + } + if (characters[index] === "\\") { + let escapeHashes = 0; + while (characters[index + 1 + escapeHashes] === "#") escapeHashes += 1; + if (escapeHashes === hashCount) { + index += 2 + escapeHashes; + continue; + } + } + index += 1; + } + } + + return characters.join(""); +} + +async function safeDirectory(root: string, path: string): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) return false; + try { + const information = await lstat(path); + return information.isDirectory() && !information.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Verifies the checked-in structure of a local clerk-ios package without + * resolving dependencies or executing its manifest. + */ +export async function localClerkIOSPackageIsStructurallyValid( + root: string, + packagePath: string, +): Promise { + const manifestPath = resolve(packagePath, "Package.swift"); + if (!(await pathIsSafelyWithinIOSRoot(root, manifestPath))) return false; + const manifest = Bun.file(manifestPath); + if (!(await manifest.exists()) || manifest.size > MAX_PACKAGE_MANIFEST_BYTES) return false; + + try { + const source = swiftManifestWithoutComments(await manifest.text()); + if (!/\bPackage\s*\(\s*name\s*:\s*"Clerk"\s*,/s.test(source)) return false; + + for (const product of CLERK_PRODUCTS) { + const library = new RegExp( + `\\.library\\s*\\(\\s*name\\s*:\\s*"${product}"\\s*,\\s*targets\\s*:\\s*\\[\\s*"${product}"\\s*\\]\\s*\\)`, + "s", + ); + const target = new RegExp( + `\\.target\\s*\\(\\s*name\\s*:\\s*"${product}"(?:\\s*,|\\s*\\))`, + "s", + ); + if (!library.test(source) || !target.test(source)) return false; + if (!(await safeDirectory(root, resolve(packagePath, "Sources", product)))) return false; + } + return true; + } catch { + return false; + } +} diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 85e8de808..31a894473 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -25,6 +25,7 @@ export interface IOSDiagnostic { | "xcode.generated-project" | "xcode.incomplete-source-membership" | "xcode.incomplete-scheme-discovery" + | "xcode.incomplete-local-secrets-discovery" | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key" @@ -178,7 +179,7 @@ export type IOSTargetSelection = | { state: "none" }; export interface IOSLocalPublishableKeyInspection { - /** False when an effective Run-scheme source may have been hidden by bounded discovery. */ + /** False when an effective runtime-key source may have been hidden by bounded discovery. */ evidenceComplete: boolean; found: boolean; source?: string; From 9f20a13a42fd89040d654d0394781f93a2fdc981 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 22:48:47 -0400 Subject: [PATCH 26/45] fix(ios): traverse referenced Xcode projects --- .../src/commands/init/ios/discovery.ts | 238 +++++++++++++++++- .../src/commands/init/ios/inspect.test.ts | 129 +++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 17 +- 3 files changed, 380 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index ac5f5631c..57122b333 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -1,11 +1,22 @@ -import { readdir, realpath, stat } from "node:fs/promises"; +import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { + asString, + asStringArray, + buildPbxParentIndex, + isRecord, + resolvePbxFilePath, + type PbxObjects, +} from "./pbx.ts"; import type { IOSWorkspaceInspection } from "./types.ts"; const MAX_DISCOVERY_DEPTH = 3; const MAX_EXHAUSTIVE_DISCOVERY_DEPTH = 24; const MAX_DISCOVERY_DIRECTORIES = 10_000; const MAX_DISCOVERED_CONTAINERS = 1_000; +const MAX_PROJECT_REFERENCE_DEPTH = 24; +const MAX_PBXPROJ_BYTES = 15_000_000; const IGNORED_DIRECTORIES = new Set([ ".build", ".git", @@ -23,6 +34,14 @@ export interface IOSContainerDiscovery { workspacePaths: string[]; /** False when a bounded or unreadable traversal could have hidden another container. */ complete: boolean; + /** False when a PBXProject project-reference closure could not be proven complete. */ + projectReferencesComplete: boolean; +} + +export interface IOSProjectReferenceDiscovery { + /** Verified project containers in the transitive closure, including valid seeds. */ + projectPaths: string[]; + complete: boolean; } export interface IOSContainerDiscoveryOptions { @@ -164,6 +183,212 @@ async function walkContainers( } } +function normalizePbxObjects(value: unknown): PbxObjects | undefined { + if (!isRecord(value)) return undefined; + const objects: PbxObjects = {}; + for (const [id, object] of Object.entries(value)) { + if (!isRecord(object)) return undefined; + objects[id] = object; + } + return objects; +} + +async function referencedProjectsForProject( + root: string, + projectPath: string, +): Promise<{ + projectPaths: string[]; + complete: boolean; + valid: boolean; + canonicalProjectPath?: string; +}> { + if (!projectPath.endsWith(".xcodeproj")) { + return { projectPaths: [], complete: false, valid: false }; + } + if (!(await pathIsSafelyWithinIOSRoot(root, projectPath))) { + return { projectPaths: [], complete: false, valid: false }; + } + + let canonicalProjectPath: string; + try { + const projectInfo = await lstat(projectPath); + if (!projectInfo.isDirectory() || projectInfo.isSymbolicLink()) { + return { projectPaths: [], complete: false, valid: false }; + } + canonicalProjectPath = await realpath(projectPath); + } catch { + return { projectPaths: [], complete: false, valid: false }; + } + + const pbxprojPath = resolve(projectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) { + return { projectPaths: [], complete: false, valid: true }; + } + + let source: string; + try { + const projectFileInfo = await lstat(pbxprojPath); + if ( + !projectFileInfo.isFile() || + projectFileInfo.isSymbolicLink() || + projectFileInfo.size > MAX_PBXPROJ_BYTES + ) { + return { projectPaths: [], complete: false, valid: true }; + } + source = await readFile(pbxprojPath, "utf8"); + } catch { + return { projectPaths: [], complete: false, valid: true }; + } + + let archive: Record; + try { + const parsed: unknown = parsePbxProject(source); + if (!isRecord(parsed)) throw new Error("invalid project root"); + archive = parsed; + } catch { + return { projectPaths: [], complete: false, valid: true }; + } + + const objects = normalizePbxObjects(archive.objects); + const rootObjectId = asString(archive.rootObject); + const projectObject = rootObjectId && objects ? objects[rootObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") { + return { projectPaths: [], complete: false, valid: true }; + } + + const projectReferences = projectObject.projectReferences; + if (projectReferences == null) { + return { + projectPaths: [], + complete: true, + valid: true, + canonicalProjectPath, + }; + } + if (!Array.isArray(projectReferences)) { + return { projectPaths: [], complete: false, valid: true }; + } + + const projectDirectory = dirname(projectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); + const parents = buildPbxParentIndex(objects); + const parentCounts = new Map(); + for (const object of Object.values(objects)) { + for (const childId of asStringArray(object.children)) { + parentCounts.set(childId, (parentCounts.get(childId) ?? 0) + 1); + } + } + const projectPaths = new Set(); + let complete = true; + for (const projectReference of projectReferences) { + if (!isRecord(projectReference)) { + complete = false; + continue; + } + const fileReferenceId = asString(projectReference.ProjectRef); + const fileReference = fileReferenceId ? objects[fileReferenceId] : undefined; + if (!fileReferenceId || fileReference?.isa !== "PBXFileReference") { + complete = false; + continue; + } + const parentCount = parentCounts.get(fileReferenceId) ?? 0; + const sourceTree = asString(fileReference.sourceTree) ?? ""; + const rawPath = asString(fileReference.path); + if (parentCount > 1 || (sourceTree === "" && (!rawPath || !isAbsolute(rawPath)))) { + complete = false; + continue; + } + const referencedProjectPath = resolvePbxFilePath( + fileReferenceId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!referencedProjectPath || !referencedProjectPath.endsWith(".xcodeproj")) { + complete = false; + continue; + } + projectPaths.add(referencedProjectPath); + } + + return { + projectPaths: [...projectPaths].sort(), + complete, + valid: true, + canonicalProjectPath, + }; +} + +/** + * Resolves PBXProject.projectReferences without invoking Xcode. Callers should + * seed this after filesystem, workspace, and any required project paths have + * been assembled so the returned closure covers every project they rely on. + */ +export async function discoverReferencedIOSProjects( + rootInput: string, + seedProjectPaths: Iterable, +): Promise { + const resolvedInput = resolve(rootInput); + const root = + resolvedInput.endsWith(".xcodeproj") || resolvedInput.endsWith(".xcworkspace") + ? dirname(resolvedInput) + : resolvedInput; + const seeds = [ + ...new Set( + [...seedProjectPaths].map((path) => (isAbsolute(path) ? resolve(path) : resolve(root, path))), + ), + ].sort(); + const queue = seeds.slice(0, MAX_DISCOVERED_CONTAINERS).map((path) => ({ path, depth: 0 })); + const scheduled = new Set(queue.map(({ path }) => path)); + const canonicalOwners = new Map(); + const discovered = new Set(); + let complete = seeds.length <= MAX_DISCOVERED_CONTAINERS; + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + if (!current) break; + const inspected = await referencedProjectsForProject(root, current.path); + if (!inspected.valid) { + complete = false; + continue; + } + const canonicalProjectPath = inspected.canonicalProjectPath; + if (!canonicalProjectPath) { + complete = false; + continue; + } + const canonicalOwner = canonicalOwners.get(canonicalProjectPath); + if (canonicalOwner && canonicalOwner !== current.path) { + complete = false; + continue; + } + canonicalOwners.set(canonicalProjectPath, current.path); + discovered.add(current.path); + if (!inspected.complete) complete = false; + + for (const referencedProjectPath of inspected.projectPaths) { + const absolutePath = resolve(referencedProjectPath); + if (scheduled.has(absolutePath)) continue; + if (current.depth >= MAX_PROJECT_REFERENCE_DEPTH) { + complete = false; + continue; + } + if (scheduled.size >= MAX_DISCOVERED_CONTAINERS) { + complete = false; + continue; + } + scheduled.add(absolutePath); + queue.push({ path: absolutePath, depth: current.depth + 1 }); + } + } + + return { projectPaths: [...discovered].sort(), complete }; +} + export async function discoverIOSContainers( rootInput: string, options: IOSContainerDiscoveryOptions = {}, @@ -187,10 +412,19 @@ export async function discoverIOSContainers( await walkContainers(root, 0, projects, workspaces, state); } + const projectReferenceRoot = root.endsWith(".xcodeproj") + ? dirname(root) + : root.endsWith(".xcworkspace") + ? dirname(root) + : root; + const projectReferences = await discoverReferencedIOSProjects(projectReferenceRoot, projects); + for (const projectPath of projectReferences.projectPaths) projects.add(projectPath); + return { projectPaths: [...projects].sort(), workspacePaths: [...workspaces].sort(), - complete: state.complete, + complete: state.complete && projectReferences.complete, + projectReferencesComplete: projectReferences.complete, }; } diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 760b3f899..134239339 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { lstat, mkdtemp, mkdir, readFile, rm, symlink } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { tmpdir } from "node:os"; import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; @@ -60,12 +60,46 @@ async function transformProject( transform: (objects: PbxObjects) => void, ): Promise { const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await transformProjectAt(projectPath, transform); +} + +async function transformProjectAt( + projectPath: string, + transform: (objects: PbxObjects) => void, +): Promise { const project = parsePbxProject(await Bun.file(projectPath).text()); const objects = (project as unknown as { objects: PbxObjects }).objects; transform(objects); await Bun.write(projectPath, buildPbxProject(project)); } +async function addProjectReference( + ownerProjectPath: string, + referencedProjectPath: string, + referenceId: string, +): Promise { + await transformProjectAt(join(ownerProjectPath, "project.pbxproj"), (objects) => { + objects[referenceId] = { + isa: "PBXFileReference", + lastKnownFileType: "wrapper.pb-project", + path: relative(dirname(ownerProjectPath), referencedProjectPath), + sourceTree: "SOURCE_ROOT", + }; + objects[IOS_FIXTURE_IDS.project]!.projectReferences = [{ ProjectRef: referenceId }]; + }); +} + +async function makeFixtureExtensionOwnSource( + projectRoot: string, + sourcePath: string, +): Promise { + await transformProject(projectRoot, (objects) => { + objects[IOS_FIXTURE_IDS.appTarget]!.productType = "com.apple.product-type.app-extension"; + objects[IOS_FIXTURE_IDS.appFile]!.path = relative(projectRoot, sourcePath); + objects[IOS_FIXTURE_IDS.appFile]!.sourceTree = "SOURCE_ROOT"; + }); +} + async function addSynchronizedTargetRoot(root: string, path = "Synced"): Promise { const groupId = "474747474747474747474747"; await transformProject(root, (objects) => { @@ -234,6 +268,99 @@ describe("discoverIOSContainers", () => { expect(discovery.complete).toBe(true); }); + + test("traverses referenced projects inside ignored directories and records shared source ownership", async () => { + const root = await fixture({ complete: true }); + const referencedRoot = join(root, "Pods", "ReferencedApp"); + await createIOSFixture(referencedRoot, { complete: true, includeKey: false }); + await makeFixtureExtensionOwnSource(referencedRoot, join(root, "MyApp", "MyAppApp.swift")); + const primaryProject = join(root, "MyApp.xcodeproj"); + const referencedProject = join(referencedRoot, "MyApp.xcodeproj"); + await addProjectReference(primaryProject, referencedProject, "515151515151515151515151"); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + const memberships = await inspectIOSSourceMembership(root); + const owners = memberships.filter((membership) => + membership.files.some((file) => file.relativePath === "MyApp/MyAppApp.swift"), + ); + + expect(discovery).toMatchObject({ complete: true, projectReferencesComplete: true }); + expect(discovery.projectPaths).toEqual([primaryProject, referencedProject].sort()); + expect(owners).toHaveLength(2); + expect(owners.every((membership) => membership.complete)).toBe(true); + expect(owners.map((membership) => membership.projectPath).sort()).toEqual([ + "MyApp.xcodeproj", + "Pods/ReferencedApp/MyApp.xcodeproj", + ]); + }); + + test("marks external sibling project references incomplete for semantic ownership", async () => { + const parent = await mkdtemp(join(tmpdir(), "clerk-ios-project-reference-")); + temporaryDirectories.push(parent); + const root = join(parent, "App"); + const siblingRoot = join(parent, "Sibling"); + await createIOSFixture(root, { complete: true }); + await createIOSFixture(siblingRoot, { complete: true, includeKey: false }); + await makeFixtureExtensionOwnSource(siblingRoot, join(root, "MyApp", "MyAppApp.swift")); + const primaryProject = join(root, "MyApp.xcodeproj"); + await addProjectReference( + primaryProject, + join(siblingRoot, "MyApp.xcodeproj"), + "525252525252525252525252", + ); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + + expect(discovery).toMatchObject({ complete: false, projectReferencesComplete: false }); + expect(discovery.projectPaths).toEqual([primaryProject]); + expect(inspection.selection).toMatchObject({ state: "selected", targetName: "MyApp" }); + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-source-membership", + severity: "warning", + }), + ); + expect(memberships.length).toBeGreaterThan(0); + expect(memberships.every((membership) => !membership.complete)).toBe(true); + }); + + test("terminates complete project-reference cycles", async () => { + const root = await fixture({ complete: true }); + const referencedRoot = join(root, "Pods", "ReferencedApp"); + await createIOSFixture(referencedRoot, { complete: true, includeKey: false }); + await makeFixtureExtensionOwnSource( + referencedRoot, + join(referencedRoot, "MyApp", "MyAppApp.swift"), + ); + const primaryProject = join(root, "MyApp.xcodeproj"); + const referencedProject = join(referencedRoot, "MyApp.xcodeproj"); + await addProjectReference(primaryProject, referencedProject, "535353535353535353535353"); + await addProjectReference(referencedProject, primaryProject, "545454545454545454545454"); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + + expect(discovery).toMatchObject({ complete: true, projectReferencesComplete: true }); + expect(discovery.projectPaths).toEqual([primaryProject, referencedProject].sort()); + }); + + test("fails project-reference discovery closed for malformed records", async () => { + const root = await fixture({ complete: true }); + await transformProject(root, (objects) => { + objects[IOS_FIXTURE_IDS.project]!.projectReferences = [{}]; + }); + + const discovery = await discoverIOSContainers(root, { exhaustive: true }); + const inspection = await inspectIOSProject(root); + + expect(discovery).toMatchObject({ complete: false, projectReferencesComplete: false }); + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + }); }); describe("inspectIOSProject", () => { diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 679005d0b..3ef64fbd4 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -11,6 +11,7 @@ import { } from "./build-settings.ts"; import { discoverIOSContainers, + discoverReferencedIOSProjects, inspectWorkspace, maskXMLComments, pathIsSafelyWithinIOSRoot, @@ -1882,6 +1883,9 @@ export async function inspectIOSProject( for (const projectPath of workspace.localProjectPaths) projectPaths.add(projectPath); } + const referencedProjects = await discoverReferencedIOSProjects(root, projectPaths); + for (const projectPath of referencedProjects.projectPaths) projectPaths.add(projectPath); + if (projectPaths.size === 0) { diagnostics.push({ code: "xcode.no-project", @@ -1907,6 +1911,17 @@ export async function inspectIOSProject( if (options.exhaustiveContainerDiscovery === true && !discovered.complete) { for (const membership of sourceMemberships) membership.complete = false; } + if (!referencedProjects.complete) { + for (const membership of sourceMemberships) membership.complete = false; + for (const target of appTargets) target.swift.evidenceComplete = false; + diagnostics.push({ + code: "xcode.incomplete-source-membership", + severity: "warning", + message: + "One or more referenced Xcode projects could not be inspected safely; source ownership checks are incomplete.", + evidence: [], + }); + } appTargets.sort( (a, b) => a.projectPath.localeCompare(b.projectPath) || @@ -1968,7 +1983,7 @@ export async function inspectIOSProject( ...(selection.state === "selected" ? [resolve(root, selection.projectPath)] : []), ...discovered.workspacePaths, ], - discovered.complete, + discovered.complete && referencedProjects.complete, inlinePublishableKeyCandidates, preferredRuntimeKeyCandidateKind(selectedAppTarget), diagnostics, From 6f4cb24688e341d4bfd690d84eaeac7f451f859b Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 23:23:43 -0400 Subject: [PATCH 27/45] fix(ios): bound project metadata reads --- .../src/commands/init/ios/bounded-file.ts | 58 ++++++++ .../src/commands/init/ios/discovery.ts | 24 ++-- .../src/commands/init/ios/inspect.test.ts | 132 +++++++++++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 20 +-- 4 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/bounded-file.ts diff --git a/packages/cli-core/src/commands/init/ios/bounded-file.ts b/packages/cli-core/src/commands/init/ios/bounded-file.ts new file mode 100644 index 000000000..802bfb1c3 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/bounded-file.ts @@ -0,0 +1,58 @@ +import { constants as fsConstants } from "node:fs"; +import { open } from "node:fs/promises"; + +const READ_CHUNK_BYTES = 64 * 1024; + +export type BoundedRegularFileReadResult = + | { status: "ok"; bytes: Uint8Array } + | { status: "missing" | "not-regular" | "too-large" | "unreadable" }; + +function missingPath(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + +/** + * Reads a regular file without letting special files block the process or a + * concurrently growing file exceed the caller's memory bound. Type, size, + * and bytes all come from the same descriptor so a replacement at the path + * cannot invalidate the checks performed before the read. + */ +export async function readBoundedRegularFile( + path: string, + maxBytes: number, +): Promise { + let handle; + try { + handle = await open( + path, + fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW, + ); + } catch (error) { + return { status: missingPath(error) ? "missing" : "unreadable" }; + } + + try { + const info = await handle.stat(); + if (!info.isFile()) return { status: "not-regular" }; + if (info.size > maxBytes) return { status: "too-large" }; + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + while (true) { + const bytesUntilOverflow = maxBytes - totalBytes + 1; + const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, bytesUntilOverflow)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, totalBytes); + if (bytesRead === 0) break; + totalBytes += bytesRead; + if (totalBytes > maxBytes) return { status: "too-large" }; + chunks.push(chunk.subarray(0, bytesRead)); + } + + return { status: "ok", bytes: Buffer.concat(chunks, totalBytes) }; + } catch { + return { status: "unreadable" }; + } finally { + await handle.close().catch(() => undefined); + } +} diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index 57122b333..b56efa386 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -1,4 +1,4 @@ -import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises"; +import { lstat, readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; import { @@ -9,6 +9,7 @@ import { resolvePbxFilePath, type PbxObjects, } from "./pbx.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; import type { IOSWorkspaceInspection } from "./types.ts"; const MAX_DISCOVERY_DEPTH = 3; @@ -225,20 +226,11 @@ async function referencedProjectsForProject( return { projectPaths: [], complete: false, valid: true }; } - let source: string; - try { - const projectFileInfo = await lstat(pbxprojPath); - if ( - !projectFileInfo.isFile() || - projectFileInfo.isSymbolicLink() || - projectFileInfo.size > MAX_PBXPROJ_BYTES - ) { - return { projectPaths: [], complete: false, valid: true }; - } - source = await readFile(pbxprojPath, "utf8"); - } catch { + const projectFile = await readBoundedRegularFile(pbxprojPath, MAX_PBXPROJ_BYTES); + if (projectFile.status !== "ok") { return { projectPaths: [], complete: false, valid: true }; } + const source = new TextDecoder().decode(projectFile.bytes); let archive: Record; try { @@ -517,9 +509,9 @@ export async function inspectWorkspace( if (!(await pathIsSafelyWithinIOSRoot(root, contentsPath))) { throw new Error("external workspace"); } - const file = Bun.file(contentsPath); - if (!(await file.exists()) || file.size > 2_000_000) throw new Error("unreadable workspace"); - xml = maskXMLComments(await file.text()); + const file = await readBoundedRegularFile(contentsPath, 2_000_000); + if (file.status !== "ok") throw new Error("unreadable workspace"); + xml = maskXMLComments(new TextDecoder().decode(file.bytes)); } catch { return { inspection: { path: relativeIOSPath(root, workspacePath), projectPaths: [] }, diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 134239339..bb5bcd2de 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; -import { lstat, mkdtemp, mkdir, readFile, rm, symlink } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, readFile, rm, symlink, truncate } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { tmpdir } from "node:os"; import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; @@ -11,6 +11,44 @@ import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts const temporaryDirectories: string[] = []; const FILE_TRANSACTION_MODULE = `${import.meta.dir}/file-transaction.ts`; +const INSPECT_MODULE = `${import.meta.dir}/inspect.ts`; +const DISCOVERY_MODULE = `${import.meta.dir}/discovery.ts`; + +async function runBoundedInspectionChild(source: string): Promise { + const child = Bun.spawn([process.execPath, "-e", source], { + stdout: "pipe", + stderr: "pipe", + }); + let timeout: ReturnType | undefined; + const outcome = await Promise.race([ + child.exited.then((exitCode) => ({ status: "exited" as const, exitCode })), + new Promise<{ status: "timeout" }>((resolve) => { + timeout = setTimeout(() => resolve({ status: "timeout" }), 3_000); + }), + ]); + if (timeout) clearTimeout(timeout); + if (outcome.status === "timeout") { + child.kill("SIGKILL"); + await child.exited; + throw new Error("iOS inspection blocked while reading a non-regular file"); + } + + const [stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (outcome.exitCode !== 0) { + throw new Error(`iOS inspection child failed: ${stderr.trim()}`); + } + return stdout.trim(); +} + +function createFIFO(path: string): void { + const result = Bun.spawnSync(["mkfifo", path], { stdout: "ignore", stderr: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`Could not create FIFO: ${result.stderr.toString().trim()}`); + } +} async function fixture(options: Parameters[1] = {}): Promise { const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); @@ -695,6 +733,34 @@ let package = Package( } }); + test("rejects an entitlements FIFO without blocking inspection", async () => { + if (process.platform === "win32") return; + const root = await fixture({ complete: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await rm(entitlementsPath); + createFIFO(entitlementsPath); + + const output = await runBoundedInspectionChild(` + const { inspectIOSProject } = await import(${JSON.stringify(INSPECT_MODULE)}); + const result = await inspectIOSProject(${JSON.stringify(root)}); + console.log(JSON.stringify(result.diagnostics.map((diagnostic) => diagnostic.code))); + `); + + expect(JSON.parse(output)).toContain("xcode.unreadable-entitlements"); + }, 10_000); + + test("rejects oversized entitlements before loading their bytes", async () => { + const root = await fixture({ complete: true }); + await truncate(join(root, "MyApp", "MyApp.entitlements"), 2_000_001); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toBeUndefined(); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.unreadable-entitlements" }), + ); + }); + test("resolves matching associated-domain variables across device and simulator contexts", async () => { const root = await fixture({ complete: true }); await addTargetBuildSettings(root, [ @@ -883,6 +949,39 @@ let package = Package( expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); }); + test("rejects a workspace metadata FIFO without blocking inspection", async () => { + if (process.platform === "win32") return; + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + const contentsPath = join(workspace, "contents.xcworkspacedata"); + await rm(contentsPath); + createFIFO(contentsPath); + + const output = await runBoundedInspectionChild(` + const { inspectWorkspace } = await import(${JSON.stringify(DISCOVERY_MODULE)}); + const result = await inspectWorkspace(${JSON.stringify(root)}, ${JSON.stringify(workspace)}); + console.log(JSON.stringify(result)); + `); + + expect(JSON.parse(output)).toEqual({ + inspection: { path: "MyApp.xcworkspace", projectPaths: [] }, + localProjectPaths: [], + }); + }, 10_000); + + test("rejects oversized workspace metadata before loading its bytes", async () => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await truncate(join(workspace, "contents.xcworkspacedata"), 2_000_001); + + const result = await inspectWorkspace(root, workspace); + + expect(result).toEqual({ + inspection: { path: "MyApp.xcworkspace", projectPaths: [] }, + localProjectPaths: [], + }); + }); + test("does not guess when multiple application targets exist", async () => { const root = await fixture({ secondTarget: true }); const inspection = await inspectIOSProject(root); @@ -1976,6 +2075,37 @@ struct MyApp: App { ).toBe(true); }); + test("rejects a project metadata FIFO without blocking inspection", async () => { + if (process.platform === "win32") return; + const root = await fixture({ complete: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await rm(projectPath); + createFIFO(projectPath); + + const output = await runBoundedInspectionChild(` + const { inspectIOSProject } = await import(${JSON.stringify(INSPECT_MODULE)}); + const result = await inspectIOSProject(${JSON.stringify(root)}); + console.log(JSON.stringify(result.diagnostics.map((diagnostic) => diagnostic.code))); + `); + + expect(JSON.parse(output)).toContain("xcode.malformed-project"); + }, 10_000); + + test("rejects oversized project metadata before loading its bytes", async () => { + const root = await fixture({ complete: true }); + await truncate(join(root, "MyApp.xcodeproj", "project.pbxproj"), 15_000_001); + + const inspection = await inspectIOSProject(root); + + expect(inspection.selection.state).toBe("none"); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.malformed-project", + message: expect.stringContaining("too large"), + }), + ); + }); + test("detects generated projects and never mutates the inspected tree", async () => { const root = await fixture({ complete: true, generated: "xcodegen" }); const before = await treeDigest(root); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 3ef64fbd4..d5a664a2d 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, stat } from "node:fs/promises"; +import { readdir, stat } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; import { parseEnvFile } from "../../../lib/dotenv.ts"; @@ -9,6 +9,7 @@ import { resolveEntitlementsAbsolutePath, type EntitlementBuildContext, } from "./build-settings.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; import { discoverIOSContainers, discoverReferencedIOSProjects, @@ -51,6 +52,7 @@ import type { const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; +const MAX_ENTITLEMENTS_BYTES = 2_000_000; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; @@ -909,8 +911,8 @@ async function inspectEntitlements( diagnostics: IOSDiagnostic[], ): Promise { const relativePath = relativeIOSPath(root, absolutePath); - const file = Bun.file(absolutePath); - if (!(await file.exists())) { + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "missing") { diagnostics.push({ code: "xcode.missing-entitlements", severity: "warning", @@ -922,7 +924,8 @@ async function inspectEntitlements( } try { - const bytes = new Uint8Array(await file.arrayBuffer()); + if (file.status !== "ok") throw new Error("unreadable entitlements"); + const bytes = file.bytes; if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { throw new Error("binary plist"); } @@ -1475,8 +1478,8 @@ async function parseProject( }); return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; } - const file = Bun.file(pbxprojPath); - if (!(await file.exists())) { + const file = await readBoundedRegularFile(pbxprojPath, MAX_PBXPROJ_BYTES); + if (file.status === "missing") { diagnostics.push({ code: "xcode.missing-project-file", severity: "error", @@ -1485,7 +1488,7 @@ async function parseProject( }); return { inspection: emptyInspection(), appTargets: [], appTargetCandidates: [], diagnostics }; } - if (file.size > MAX_PBXPROJ_BYTES) { + if (file.status === "too-large") { diagnostics.push({ code: "xcode.malformed-project", severity: "error", @@ -1497,7 +1500,8 @@ async function parseProject( let archive: Record; try { - const parsed: unknown = parsePbxProject(await readFile(pbxprojPath, "utf8")); + if (file.status !== "ok") throw new Error("unreadable project"); + const parsed: unknown = parsePbxProject(new TextDecoder().decode(file.bytes)); if (!isRecord(parsed)) throw new Error("invalid project root"); archive = parsed; } catch (error) { From e40db4fc5ee7500cab6a7bbed8e97e55bf6ec032 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 00:00:54 -0400 Subject: [PATCH 28/45] fix(init): bind scheme keys to launched target --- .../src/commands/init/ios/discovery.ts | 22 ++++++++++++++ .../src/commands/init/ios/inspect.test.ts | 29 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 21 +++++--------- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index b56efa386..b6e878e6b 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -493,6 +493,28 @@ export function maskXMLComments(source: string): string { return chunks.join(""); } +/** + * Returns the single target reference that the Xcode Run action actually + * launches. References used only for MacroExpansion or other scheme metadata + * are not runtime ownership evidence. + */ +export function xcodeSchemeRunnableReferenceAttributes( + launchActionBody: string, +): string | undefined { + const runnables = [ + ...launchActionBody.matchAll( + /]*>([\s\S]*?)<\/BuildableProductRunnable>/g, + ), + ]; + if (runnables.length !== 1) return undefined; + + const references = [ + ...(runnables[0]?.[1] ?? "").matchAll(/]*)\/?\s*>/g), + ]; + if (references.length !== 1) return undefined; + return references[0]?.[1]; +} + /** * Reads only project references from an Xcode workspace. We deliberately do * not use Xcode or resolve packages. Unsupported/external references remain diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index bb5bcd2de..d3b838aa4 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1366,6 +1366,35 @@ struct MyApp: App { expect(JSON.stringify(inspection)).not.toContain(schemeKey); }); + test("binds a Run-scheme key to the launched target instead of MacroExpansion", async () => { + const root = await fixture({ includeKey: false, secondTarget: true }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemeKey = `pk_test_${Buffer.from("launched.clerk.example$").toString("base64")}`; + await Bun.write( + join(schemeDirectory, "AdminApp.xcscheme"), + ``, + ); + + const macroExpansionTarget = await inspectIOSProject(root, { target: "MyApp" }); + const launchedTarget = await inspectIOSProject(root, { target: "AdminApp" }); + + expect(macroExpansionTarget.localPublishableKey).toMatchObject({ + evidenceComplete: true, + found: false, + candidateSources: [], + }); + expect(launchedTarget.localPublishableKey).toMatchObject({ + evidenceComplete: true, + found: true, + source: "MyApp.xcodeproj/xcshareddata/xcschemes/AdminApp.xcscheme", + frontendApiHost: "launched.clerk.example", + conflict: false, + }); + expect(JSON.stringify(macroExpansionTarget)).not.toContain(schemeKey); + expect(JSON.stringify(launchedTarget)).not.toContain(schemeKey); + }); + test("fails closed when bounded scheme discovery hides a conflicting workspace key", async () => { const root = await fixture({ includeKey: false, workspace: true }); const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index d5a664a2d..8be47a23a 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -17,6 +17,7 @@ import { maskXMLComments, pathIsSafelyWithinIOSRoot, relativeIOSPath, + xcodeSchemeRunnableReferenceAttributes, xmlAttribute, } from "./discovery.ts"; import { hasInterruptedIOSFileTransaction } from "./file-transaction.ts"; @@ -303,20 +304,14 @@ async function schemePublishableKeyCandidates( for (const launchAction of xml.matchAll(/]*>([\s\S]*?)<\/LaunchAction>/g)) { const body = launchAction[1] ?? ""; - const referencesTarget = [...body.matchAll(/]*)>/g)].some( - (reference) => { - const attributes = reference[1] ?? ""; - if (xmlAttribute(attributes, "BlueprintIdentifier") !== selection.targetId) { - return false; - } - const container = xmlAttribute(attributes, "ReferencedContainer")?.replace( - /^container:/, - "", - ); - return schemeReferencesSelectedProject(root, path, selection.projectPath, container); - }, + const runnableReference = xcodeSchemeRunnableReferenceAttributes(body); + if (!runnableReference) continue; + if (xmlAttribute(runnableReference, "BlueprintIdentifier") !== selection.targetId) continue; + const container = xmlAttribute(runnableReference, "ReferencedContainer")?.replace( + /^container:/, + "", ); - if (!referencesTarget) continue; + if (!schemeReferencesSelectedProject(root, path, selection.projectPath, container)) continue; for (const variable of body.matchAll(/]*)\/?\s*>/g)) { const attributes = variable[1] ?? ""; From b07bc77f9e547489fd086911af31fd0b15724bdd Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 09:36:14 -0400 Subject: [PATCH 29/45] refactor(ios): simplify custom key inspection --- .../src/commands/init/ios/inspect.test.ts | 886 +----------- .../cli-core/src/commands/init/ios/inspect.ts | 767 +--------- .../src/commands/init/ios/products.test.ts | 66 +- .../src/commands/init/ios/products.ts | 27 +- .../src/commands/init/ios/swift.test.ts | 472 +------ .../cli-core/src/commands/init/ios/swift.ts | 1228 +---------------- .../cli-core/src/commands/init/ios/types.ts | 23 +- 7 files changed, 88 insertions(+), 3381 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index d3b838aa4..3e0b3fc02 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -138,23 +138,6 @@ async function makeFixtureExtensionOwnSource( }); } -async function addSynchronizedTargetRoot(root: string, path = "Synced"): Promise { - const groupId = "474747474747474747474747"; - await transformProject(root, (objects) => { - objects[groupId] = { - isa: "PBXFileSystemSynchronizedRootGroup", - path, - sourceTree: "", - }; - objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [groupId]; - }); -} - -function localSecretsPlist(host: string): string { - const encodedHost = Buffer.from(`${host}$`).toString("base64"); - return `CLERK_PUBLISHABLE_KEYpk_test_${encodedHost}`; -} - async function writeStructurallyValidLocalClerkPackage(root: string): Promise { const packageRoot = join(root, "LocalClerk"); await mkdir(join(packageRoot, "Sources", "ClerkKit"), { recursive: true }); @@ -214,52 +197,6 @@ async function addTargetBuildSettings( ); } -async function writeSelectedTargetRunSchemeKey(root: string, value: string): Promise { - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemePath = join(schemeDirectory, "MyApp.xcscheme"); - await Bun.write( - schemePath, - ``, - ); - return schemePath; -} - -function selectedTargetRunScheme(value: string, referencedContainer?: string): string { - const containerAttribute = referencedContainer - ? ` ReferencedContainer="container:${referencedContainer}"` - : ""; - return ``; -} - -async function fillProjectSchemeLimit(root: string, finalScheme: string): Promise { - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - await Promise.all( - Array.from({ length: 99 }, (_, index) => - Bun.write(join(schemeDirectory, `A${String(index).padStart(3, "0")}.xcscheme`), ""), - ), - ); - await Bun.write(join(schemeDirectory, "ZRuntime.xcscheme"), finalScheme); -} - -async function writeWorkspaceRunScheme(root: string, name: string, source: string): Promise { - const schemeDirectory = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - await Bun.write(join(schemeDirectory, name), source); -} - -async function writeDeepWorkspaceRunScheme(root: string, source: string): Promise { - const workspace = join(root, "One", "Two", "Three", "Four", "Deep.xcworkspace"); - const schemeDirectory = join(workspace, "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - await Bun.write( - join(workspace, "contents.xcworkspacedata"), - '', - ); - await Bun.write(join(schemeDirectory, "DeepRuntime.xcscheme"), source); -} - afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -485,12 +422,11 @@ describe("inspectIOSProject", () => { status: "complete", sourceFilesScanned: 1, }); - expect(target?.runtimeKeySinks).toEqual([]); expect(inspection.localPublishableKey).toEqual({ evidenceComplete: true, found: false, conflict: false, - candidateSources: [".env"], + candidateSources: [], invalidSources: [], }); const fixtureKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; @@ -1075,181 +1011,9 @@ let package = Package( ]); }); - test("reads a target-owned LocalSecrets.plist without exposing its key", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: true, - source: "MyApp/LocalSecrets.plist", - frontendApiHost: "native.clerk.example", - instanceType: "production", - conflict: false, - candidateSources: ["MyApp/LocalSecrets.plist"], - invalidSources: [], - }); - expect(inspection.appTargets[0]?.runtimeKeySinks).toEqual([ - { kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }, - ]); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - message: expect.stringContaining("LocalSecrets.plist"), - evidence: [{ path: "MyApp/LocalSecrets.plist", keyPath: "CLERK_PUBLISHABLE_KEY" }], - }), - ); - expect(JSON.stringify(inspection)).not.toContain("pk_live_"); - }); - - test("reads LocalSecrets.plist from a completely discovered synchronized target root", async () => { - const root = await fixture({ complete: true, includeKey: false }); - await addSynchronizedTargetRoot(root); - await mkdir(join(root, "Synced", "Configuration"), { recursive: true }); - await Bun.write( - join(root, "Synced", "Configuration", "LocalSecrets.plist"), - localSecretsPlist("synchronized.clerk.example"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - source: "Synced/Configuration/LocalSecrets.plist", - frontendApiHost: "synchronized.clerk.example", - }); - }); - - test("fails closed when a discovered LocalSecrets.plist is followed by depth-truncated evidence", async () => { - const root = await fixture({ complete: true, includeKey: false }); - await addSynchronizedTargetRoot(root); - await mkdir(join(root, "Synced", "00-visible"), { recursive: true }); - await Bun.write( - join(root, "Synced", "00-visible", "LocalSecrets.plist"), - localSecretsPlist("visible.clerk.example"), - ); - const hiddenDirectory = join( - root, - "Synced", - "10-deep", - "level-1", - "level-2", - "level-3", - "level-4", - "level-5", - ); - await mkdir(hiddenDirectory, { recursive: true }); - await Bun.write( - join(hiddenDirectory, "LocalSecrets.plist"), - localSecretsPlist("hidden.clerk.example"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: ["Synced/00-visible/LocalSecrets.plist"], - invalidSources: [], - }); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "xcode.incomplete-local-secrets-discovery", - message: expect.stringContaining("MyApp"), - }), - ); - expect(inspection.localPublishableKey).not.toHaveProperty("source"); - expect(inspection.localPublishableKey).not.toHaveProperty("frontendApiHost"); - }); - - test("fails closed when synchronized LocalSecrets discovery exceeds its file bound", async () => { - const root = await fixture({ complete: true, includeKey: false }); - await addSynchronizedTargetRoot(root); - for (let index = 0; index < 21; index += 1) { - const directory = join(root, "Synced", String(index).padStart(2, "0")); - await mkdir(directory, { recursive: true }); - await Bun.write( - join(directory, "LocalSecrets.plist"), - localSecretsPlist(`bounded-${index}.clerk.example`), - ); - } - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey.evidenceComplete).toBe(false); - expect(inspection.localPublishableKey.found).toBe(false); - expect(inspection.localPublishableKey.candidateSources).toHaveLength(20); - expect(inspection.localPublishableKey).not.toHaveProperty("source"); - expect(inspection.localPublishableKey).not.toHaveProperty("frontendApiHost"); - }); - - test("fails closed when synchronized LocalSecrets discovery skips a symlink", async () => { - if (process.platform === "win32") return; - const root = await fixture({ complete: true, includeKey: false }); - await addSynchronizedTargetRoot(root); - const externalRoot = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-")); - temporaryDirectories.push(externalRoot); - await Bun.write( - join(externalRoot, "LocalSecrets.plist"), - localSecretsPlist("external.clerk.example"), - ); - await mkdir(join(root, "Synced"), { recursive: true }); - await symlink(externalRoot, join(root, "Synced", "LinkedSecrets")); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "xcode.incomplete-local-secrets-discovery", - evidence: [{ path: "Synced/LinkedSecrets" }], - }), - ); - }); - - test("fails closed when a target-owned LocalSecrets.plist resolves outside the project", async () => { - if (process.platform === "win32") return; - const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); - const externalRoot = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-")); - temporaryDirectories.push(externalRoot); - const externalPath = join(externalRoot, "LocalSecrets.plist"); - await Bun.write(externalPath, localSecretsPlist("external.clerk.example")); - await rm(join(root, "MyApp", "LocalSecrets.plist")); - await symlink(externalPath, join(root, "MyApp", "LocalSecrets.plist")); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }); - expect(inspection.appTargets[0]?.runtimeKeySinks).toEqual([]); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "xcode.incomplete-local-secrets-discovery", - evidence: [{ path: "MyApp/LocalSecrets.plist" }], - }), - ); - }); - test("treats a direct @main literal as the selected target's runtime key without exposing it", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); + const root = await fixture({ includeKey: false }); const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; - const schemeKey = `pk_live_${Buffer.from("scheme.clerk.example$").toString("base64")}`; - const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; - await writeSelectedTargetRunSchemeKey(root, schemeKey); await Bun.write( join(root, "MyApp", "MyAppApp.swift"), `import ClerkKit @@ -1280,23 +1044,15 @@ struct MyApp: App { source: "MyApp/MyAppApp.swift", frontendApiHost: "inline.clerk.example", instanceType: "development", - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - "MyApp/MyAppApp.swift", - ], + candidateSources: ["MyApp/MyAppApp.swift"], invalidSources: [], }); expect(JSON.stringify(inspection)).not.toContain(publishableKey); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); }); test("does not fall through from an invalid app-init literal to other key sources", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); + const root = await fixture({ includeKey: false }); const invalidInlineKey = "pk_test_inline-secret-must-not-leak"; - const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; - await writeSelectedTargetRunSchemeKey(root, schemeKey); await Bun.write( join(root, "MyApp", "MyAppApp.swift"), `import ClerkKit @@ -1320,617 +1076,10 @@ struct MyApp: App { found: false, source: "MyApp/MyAppApp.swift", conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - "MyApp/MyAppApp.swift", - ], + candidateSources: ["MyApp/MyAppApp.swift"], invalidSources: ["MyApp/MyAppApp.swift"], }); expect(JSON.stringify(inspection)).not.toContain(invalidInlineKey); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - }); - - test("reads an enabled publishable key from the selected target's Run scheme", async () => { - const root = await fixture({ includeKey: false }); - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; - await Bun.write( - join(schemeDirectory, "MyApp.xcscheme"), - ``, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - frontendApiHost: "scheme.clerk.example", - conflict: false, - }); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - message: expect.stringContaining("Run-scheme key"), - evidence: [ - { - path: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - keyPath: "CLERK_PUBLISHABLE_KEY", - }, - ], - }), - ); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - }); - - test("binds a Run-scheme key to the launched target instead of MacroExpansion", async () => { - const root = await fixture({ includeKey: false, secondTarget: true }); - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemeKey = `pk_test_${Buffer.from("launched.clerk.example$").toString("base64")}`; - await Bun.write( - join(schemeDirectory, "AdminApp.xcscheme"), - ``, - ); - - const macroExpansionTarget = await inspectIOSProject(root, { target: "MyApp" }); - const launchedTarget = await inspectIOSProject(root, { target: "AdminApp" }); - - expect(macroExpansionTarget.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: false, - candidateSources: [], - }); - expect(launchedTarget.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - source: "MyApp.xcodeproj/xcshareddata/xcschemes/AdminApp.xcscheme", - frontendApiHost: "launched.clerk.example", - conflict: false, - }); - expect(JSON.stringify(macroExpansionTarget)).not.toContain(schemeKey); - expect(JSON.stringify(launchedTarget)).not.toContain(schemeKey); - }); - - test("fails closed when bounded scheme discovery hides a conflicting workspace key", async () => { - const root = await fixture({ includeKey: false, workspace: true }); - const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleKey)); - await writeWorkspaceRunScheme( - root, - "WorkspaceRuntime.xcscheme", - selectedTargetRunScheme(hiddenKey, "MyApp.xcodeproj"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/ZRuntime.xcscheme"], - invalidSources: [], - }); - expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "xcode.incomplete-scheme-discovery", - severity: "warning", - evidence: expect.arrayContaining([{ path: "MyApp.xcworkspace/xcshareddata" }]), - }), - ); - expect(JSON.stringify(inspection)).not.toContain(visibleKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenKey); - }); - - test("fails closed when bounded container discovery hides a conflicting workspace scheme", async () => { - const root = await fixture({ includeKey: false }); - const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - await writeSelectedTargetRunSchemeKey(root, visibleKey); - await writeDeepWorkspaceRunScheme( - root, - selectedTargetRunScheme(hiddenKey, "../../../../MyApp.xcodeproj"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], - invalidSources: [], - }); - expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ - code: "xcode.incomplete-scheme-discovery", - severity: "warning", - evidence: expect.arrayContaining([{ path: "." }]), - }), - ); - expect(JSON.stringify(inspection)).not.toContain(visibleKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenKey); - }); - - test("fails closed when exhaustive container discovery reaches its depth bound", async () => { - const root = await fixture({ includeKey: false }); - const visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - await writeSelectedTargetRunSchemeKey(root, visibleKey); - - const nesting = Array.from({ length: 25 }, (_, index) => `Level${index}`); - const workspace = join(root, ...nesting, "Deep.xcworkspace"); - const schemeDirectory = join(workspace, "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const projectReference = `${"../".repeat(nesting.length)}MyApp.xcodeproj`; - await Bun.write( - join(workspace, "contents.xcworkspacedata"), - ``, - ); - await Bun.write( - join(schemeDirectory, "DeepRuntime.xcscheme"), - selectedTargetRunScheme(hiddenKey, projectReference), - ); - - const inspection = await inspectIOSProject(root, { exhaustiveContainerDiscovery: true }); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: false, - found: false, - conflict: false, - }); - expect(inspection.localPublishableKey.frontendApiHost).toBeUndefined(); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ code: "xcode.incomplete-scheme-discovery" }), - ); - expect(JSON.stringify(inspection)).not.toContain(visibleKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenKey); - }); - - test("keeps a proven inline key authoritative when scheme discovery is incomplete", async () => { - const root = await fixture({ includeKey: false, workspace: true }); - const inlineKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; - const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: "${inlineKey}") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); - await writeWorkspaceRunScheme( - root, - "WorkspaceRuntime.xcscheme", - selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - conflict: false, - source: "MyApp/MyAppApp.swift", - frontendApiHost: "inline.clerk.example", - }); - expect( - inspection.diagnostics.some( - (diagnostic) => diagnostic.code === "xcode.incomplete-scheme-discovery", - ), - ).toBe(false); - expect(JSON.stringify(inspection)).not.toContain(inlineKey); - expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); - }); - - test("fails incomplete scheme discovery closed when inline startup wiring is ambiguous", async () => { - const root = await fixture({ includeKey: false, workspace: true }); - const inlineKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; - const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: "${inlineKey}") - Clerk.configure(publishableKey: "${inlineKey}") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); - await writeWorkspaceRunScheme( - root, - "WorkspaceRuntime.xcscheme", - selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.appTargets[0]?.swift.configureCalls).toHaveLength(2); - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/ZRuntime.xcscheme", - "MyApp/MyAppApp.swift", - ], - invalidSources: [], - }); - expect(inspection.diagnostics).toContainEqual( - expect.objectContaining({ code: "xcode.incomplete-scheme-discovery" }), - ); - expect(JSON.stringify(inspection)).not.toContain(inlineKey); - expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); - }); - - test("keeps a proven LocalSecrets key authoritative when scheme discovery is incomplete", async () => { - const root = await fixture({ - complete: true, - includeKey: false, - localSecrets: true, - workspace: true, - }); - const visibleSchemeKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; - const hiddenSchemeKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; - await fillProjectSchemeLimit(root, selectedTargetRunScheme(visibleSchemeKey)); - await writeWorkspaceRunScheme( - root, - "WorkspaceRuntime.xcscheme", - selectedTargetRunScheme(hiddenSchemeKey, "MyApp.xcodeproj"), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - conflict: false, - source: "MyApp/LocalSecrets.plist", - frontendApiHost: "native.clerk.example", - }); - expect( - inspection.diagnostics.some( - (diagnostic) => diagnostic.code === "xcode.incomplete-scheme-discovery", - ), - ).toBe(false); - expect( - inspection.diagnostics.some( - (diagnostic) => - diagnostic.code === "clerk.unconsumed-publishable-key-source" && - diagnostic.message.includes("LocalSecrets.plist"), - ), - ).toBe(false); - expect(JSON.stringify(inspection)).not.toContain(visibleSchemeKey); - expect(JSON.stringify(inspection)).not.toContain(hiddenSchemeKey); - }); - - test("uses the LocalSecrets key proven by app-init wiring instead of a different scheme key", async () => { - const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); - const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; - const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; - await writeSelectedTargetRunSchemeKey(root, schemeKey); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: true, - source: "MyApp/LocalSecrets.plist", - frontendApiHost: "native.clerk.example", - instanceType: "production", - conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - ], - invalidSources: [], - }); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); - }); - - test("does not fall through from a malformed LocalSecrets key proven by app-init wiring", async () => { - const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); - const schemeKey = `pk_test_${Buffer.from("scheme.clerk.example$").toString("base64")}`; - const malformedLocalSecretsKey = "pk_live_local-secret-must-not-leak"; - await writeSelectedTargetRunSchemeKey(root, schemeKey); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - `CLERK_PUBLISHABLE_KEY${malformedLocalSecretsKey}`, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - source: "MyApp/LocalSecrets.plist", - conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - ], - invalidSources: ["MyApp/LocalSecrets.plist"], - }); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - expect(JSON.stringify(inspection)).not.toContain(malformedLocalSecretsKey); - }); - - test("does not fall through from an empty LocalSecrets handoff to a stale scheme key", async () => { - const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); - const schemeKey = `pk_test_${Buffer.from("stale-scheme.clerk.example$").toString("base64")}`; - await writeSelectedTargetRunSchemeKey(root, schemeKey); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - '', - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], - invalidSources: [], - }); - expect( - inspection.diagnostics.some( - (diagnostic) => diagnostic.code === "clerk.invalid-publishable-key", - ), - ).toBe(false); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - }); - - test("uses the selected target's scheme when app-init reads ProcessInfo", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); - const schemeKey = `pk_test_${Buffer.from("scheme-runtime.clerk.example$").toString("base64")}`; - const localSecretsKey = `pk_live_${Buffer.from("native.clerk.example$").toString("base64")}`; - await writeSelectedTargetRunSchemeKey(root, schemeKey); - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") - } - - var body: some Scene { WindowGroup { Text("Hello") } } -} -`, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: true, - source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - frontendApiHost: "scheme-runtime.clerk.example", - instanceType: "development", - conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - ], - invalidSources: [], - }); - expect( - inspection.diagnostics.some( - (diagnostic) => - diagnostic.code === "clerk.unconsumed-publishable-key-source" && - diagnostic.message.includes("Run-scheme key"), - ), - ).toBe(false); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - expect(JSON.stringify(inspection)).not.toContain(localSecretsKey); - }); - - test("does not synthesize Run scheme markup across XML comments", async () => { - const root = await fixture({ includeKey: false }); - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemeKey = `pk_test_${Buffer.from("comment.clerk.example$").toString("base64")}`; - await Bun.write( - join(schemeDirectory, "MyApp.xcscheme"), - `Action>Action>`, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - }); - - test("ignores a workspace scheme that references a different same-named project container", async () => { - const root = await fixture({ includeKey: false, workspace: true }); - const schemeDirectory = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemeKey = `pk_test_${Buffer.from("wrong-container.clerk.example$").toString("base64")}`; - await Bun.write( - join(schemeDirectory, "WrongContainer.xcscheme"), - ``, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }); - expect(JSON.stringify(inspection)).not.toContain(schemeKey); - }); - - test("blocks derived advice when equally effective keys point to different instances", async () => { - const root = await fixture({ includeKey: false }); - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const firstKey = `pk_test_${Buffer.from("first.clerk.example$").toString("base64")}`; - const secondKey = `pk_live_${Buffer.from("second.clerk.example$").toString("base64")}`; - const scheme = (key: string) => - ``; - await Bun.write(join(schemeDirectory, "First.xcscheme"), scheme(firstKey)); - await Bun.write(join(schemeDirectory, "Second.xcscheme"), scheme(secondKey)); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: true, - source: "MyApp.xcodeproj/xcshareddata/xcschemes/First.xcscheme", - conflict: true, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/First.xcscheme", - "MyApp.xcodeproj/xcshareddata/xcschemes/Second.xcscheme", - ], - invalidSources: [], - }); - expect( - inspection.diagnostics.some( - (diagnostic) => diagnostic.code === "clerk.conflicting-publishable-keys", - ), - ).toBe(true); - expect(JSON.stringify(inspection)).not.toContain(firstKey); - expect(JSON.stringify(inspection)).not.toContain(secondKey); - }); - - test("does not conflict a selected-target runtime key with the CLI process environment", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); - const ambientKey = `pk_test_${Buffer.from("ambient.clerk.example$").toString("base64")}`; - const previousAmbientKey = process.env.CLERK_PUBLISHABLE_KEY; - process.env.CLERK_PUBLISHABLE_KEY = ambientKey; - - try { - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toMatchObject({ - evidenceComplete: true, - found: true, - conflict: false, - source: "MyApp/LocalSecrets.plist", - frontendApiHost: "native.clerk.example", - }); - expect(inspection.localPublishableKey.candidateSources).toEqual([ - "CLERK_PUBLISHABLE_KEY environment variable", - "MyApp/LocalSecrets.plist", - ]); - expect( - inspection.diagnostics.some( - (diagnostic) => diagnostic.code === "clerk.conflicting-publishable-keys", - ), - ).toBe(false); - expect(JSON.stringify(inspection)).not.toContain(ambientKey); - } finally { - if (previousAmbientKey == null) delete process.env.CLERK_PUBLISHABLE_KEY; - else process.env.CLERK_PUBLISHABLE_KEY = previousAmbientKey; - } - }); - - test("does not fall through when the highest-precedence key is malformed", async () => { - const root = await fixture({ includeKey: false, localSecrets: true }); - const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(schemeDirectory, { recursive: true }); - const schemePath = join(schemeDirectory, "MyApp.xcscheme"); - await Bun.write( - schemePath, - ``, - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - source: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - conflict: false, - candidateSources: [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - "MyApp/LocalSecrets.plist", - ], - invalidSources: ["MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"], - }); }); test("does not treat web-framework key names as native iOS configuration", async () => { @@ -2065,31 +1214,6 @@ struct MyApp: App { expect(target?.packages.clerkKit).toBe("declared"); }); - test("does not use non-iOS or unknown LocalSecrets resource membership as a native key", async () => { - for (const platformFilter of ["maccatalyst", "futureos"]) { - const root = await fixture({ includeKey: false, localSecrets: true }); - const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const original = await Bun.file(projectFile).text(); - await Bun.write( - projectFile, - original.replace( - `${IOS_FIXTURE_IDS.localSecretsBuildFile} = { isa = PBXBuildFile; fileRef`, - `${IOS_FIXTURE_IDS.localSecretsBuildFile} = { isa = PBXBuildFile; platformFilter = ${platformFilter}; fileRef`, - ), - ); - - const inspection = await inspectIOSProject(root); - - expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }); - } - }); - test("reports malformed projects as blocked evidence", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 8be47a23a..81b49c8b6 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,8 +1,6 @@ -import { readdir, stat } from "node:fs/promises"; +import { readdir } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; -import { parseEnvFile } from "../../../lib/dotenv.ts"; -import { decodePublishableKey } from "../../../lib/fapi.ts"; import { addBuildSettingConflictDiagnostics, inspectTargetBuildConfigurations, @@ -14,11 +12,8 @@ import { discoverIOSContainers, discoverReferencedIOSProjects, inspectWorkspace, - maskXMLComments, pathIsSafelyWithinIOSRoot, relativeIOSPath, - xcodeSchemeRunnableReferenceAttributes, - xmlAttribute, } from "./discovery.ts"; import { hasInterruptedIOSFileTransaction } from "./file-transaction.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; @@ -35,7 +30,6 @@ import { type PbxObjects, } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; -import { hasIOSProvenStartupKeyWiring } from "./products.ts"; import { inspectSwiftSources } from "./swift.ts"; import type { IOSAppTarget, @@ -57,11 +51,6 @@ const MAX_ENTITLEMENTS_BYTES = 2_000_000; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; -const MAX_SCHEME_DISCOVERY_DEPTH = 6; -const MAX_SCHEME_FILES = 100; -const RUN_SCHEME_PRIORITY = 5; -const MAX_SECRET_DISCOVERY_DEPTH = 5; -const MAX_SECRET_FILES = 20; const SOURCE_IGNORES = new Set([ ".build", ".git", @@ -94,14 +83,6 @@ const sourceMembershipByInspection = new WeakMap< IOSTargetSourceMembership[] >(); -interface LocalSecretsDiscovery { - paths: string[]; - complete: boolean; - evidence: IOSSourceEvidence[]; -} - -const localSecretsDiscoveryByTarget = new WeakMap(); - function emptySwiftInspection() { return { sourceFilesScanned: 0, @@ -110,7 +91,6 @@ function emptySwiftInspection() { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], - localSecretsRuntimeBindings: [], environmentInjections: [], environmentConsumers: [], authFlowReferences: [], @@ -152,604 +132,75 @@ function buildFileIOSApplicability(object: PbxObject): { return { applies: false, recognized }; } -interface PublishableKeyCandidate { - kind: - | "inline-literal" - | "run-scheme" - | "local-secrets-plist" - | "environment-file" - | "keyless-file" - | "ambient-environment"; - value?: string; - decoded?: { frontendApiHost: string; instanceType: "development" | "production" }; - invalid?: true; - source: string; - evidence: IOSSourceEvidence[]; - priority: number; - ambient?: true; -} - -interface SchemeDiscoveryState { - paths: string[]; - complete: boolean; - incompletePaths: Set; -} - -function markSchemeDiscoveryIncomplete(state: SchemeDiscoveryState, path: string): void { - state.complete = false; - state.incompletePaths.add(path); -} - -async function collectSchemeFiles( - root: string, - directory: string, - state: SchemeDiscoveryState, - depth = 0, -): Promise { - if (depth > MAX_SCHEME_DISCOVERY_DEPTH) { - markSchemeDiscoveryIncomplete(state, directory); - return; - } - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - markSchemeDiscoveryIncomplete(state, directory); - return; - } - entries.sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const path = resolve(directory, entry.name); - if (entry.isSymbolicLink()) { - let couldHideScheme = entry.name.endsWith(".xcscheme"); - if (!couldHideScheme) { - if (!(await pathIsSafelyWithinIOSRoot(root, path))) { - couldHideScheme = true; - } else { - try { - couldHideScheme = (await stat(path)).isDirectory(); - } catch { - couldHideScheme = true; - } - } - } - if (couldHideScheme) markSchemeDiscoveryIncomplete(state, path); - continue; - } - if (entry.isDirectory()) { - if (state.paths.length >= MAX_SCHEME_FILES) { - markSchemeDiscoveryIncomplete(state, path); - } else { - await collectSchemeFiles(root, path, state, depth + 1); - } - } else if (entry.isFile() && entry.name.endsWith(".xcscheme")) { - if (state.paths.length >= MAX_SCHEME_FILES) { - markSchemeDiscoveryIncomplete(state, path); - } else if (await pathIsSafelyWithinIOSRoot(root, path)) { - state.paths.push(path); - } else { - markSchemeDiscoveryIncomplete(state, path); - } - } - } -} - -function enclosingXcodeContainer(path: string): string | undefined { - let current = dirname(path); - while (true) { - if (current.endsWith(".xcodeproj") || current.endsWith(".xcworkspace")) return current; - const parent = dirname(current); - if (parent === current) return undefined; - current = parent; - } -} - -function schemeReferencesSelectedProject( - root: string, - schemePath: string, - selectedProjectPath: string, - referencedContainer: string | undefined, -): boolean { - const selectedProject = resolve(root, selectedProjectPath); - const enclosingContainer = enclosingXcodeContainer(schemePath); - if (!referencedContainer) { - return ( - enclosingContainer?.endsWith(".xcodeproj") === true && enclosingContainer === selectedProject - ); - } - const base = enclosingContainer ? dirname(enclosingContainer) : root; - const normalized = referencedContainer.replaceAll("\\", "/"); - return resolve(base, ...normalized.split("/")) === selectedProject; -} - -async function schemePublishableKeyCandidates( - root: string, - selection: IOSTargetSelection, - schemeRoots: string[], -): Promise<{ - candidates: PublishableKeyCandidate[]; - complete: boolean; - incompleteEvidence: IOSSourceEvidence[]; -}> { - if (selection.state !== "selected") { - return { candidates: [], complete: true, incompleteEvidence: [] }; - } - const state: SchemeDiscoveryState = { - paths: [], - complete: true, - incompletePaths: new Set(), - }; - for (const schemeRoot of [...new Set(schemeRoots)].sort()) { - if (await pathIsSafelyWithinIOSRoot(root, schemeRoot)) { - await collectSchemeFiles(root, schemeRoot, state); - } else { - markSchemeDiscoveryIncomplete(state, schemeRoot); - } - } - const candidates: PublishableKeyCandidate[] = []; - - for (const path of state.paths.sort()) { - const file = Bun.file(path); - if (!(await file.exists()) || file.size > 2_000_000) { - markSchemeDiscoveryIncomplete(state, path); - continue; - } - let xml: string; - try { - xml = maskXMLComments(await file.text()); - } catch { - markSchemeDiscoveryIncomplete(state, path); - continue; - } - - for (const launchAction of xml.matchAll(/]*>([\s\S]*?)<\/LaunchAction>/g)) { - const body = launchAction[1] ?? ""; - const runnableReference = xcodeSchemeRunnableReferenceAttributes(body); - if (!runnableReference) continue; - if (xmlAttribute(runnableReference, "BlueprintIdentifier") !== selection.targetId) continue; - const container = xmlAttribute(runnableReference, "ReferencedContainer")?.replace( - /^container:/, - "", - ); - if (!schemeReferencesSelectedProject(root, path, selection.projectPath, container)) continue; - - for (const variable of body.matchAll(/]*)\/?\s*>/g)) { - const attributes = variable[1] ?? ""; - if (xmlAttribute(attributes, "key") !== "CLERK_PUBLISHABLE_KEY") continue; - if ((xmlAttribute(attributes, "isEnabled") ?? "YES").toUpperCase() === "NO") continue; - const value = xmlAttribute(attributes, "value")?.trim(); - if (!value) continue; - const source = relativeIOSPath(root, path); - candidates.push({ - kind: "run-scheme", - value, - source, - evidence: [{ path: source, keyPath: "LaunchAction.EnvironmentVariables" }], - priority: RUN_SCHEME_PRIORITY, - }); - } - } - } - return { - candidates, - complete: state.complete, - incompleteEvidence: [...state.incompletePaths] - .sort() - .map((path) => ({ path: relativeIOSPath(root, path) })), - }; -} - -async function collectLocalSecretsPlists( - root: string, - directory: string, - output: string[], - state: { complete: boolean; incompletePaths: Set }, - depth = 0, -): Promise { - if ( - depth > MAX_SECRET_DISCOVERY_DEPTH || - output.length >= MAX_SECRET_FILES || - !(await pathIsSafelyWithinIOSRoot(root, directory)) - ) { - state.complete = false; - state.incompletePaths.add(directory); - return; - } - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - state.complete = false; - state.incompletePaths.add(directory); - return; - } - - entries.sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - const absolutePath = resolve(directory, entry.name); - if (output.length >= MAX_SECRET_FILES) { - const couldHideLocalSecrets = - (entry.isDirectory() || entry.isSymbolicLink()) && - !SOURCE_IGNORES.has(entry.name) && - !entry.name.startsWith("."); - if (couldHideLocalSecrets || (entry.isFile() && entry.name === "LocalSecrets.plist")) { - state.complete = false; - state.incompletePaths.add(absolutePath); - return; - } - continue; - } - if (entry.isSymbolicLink()) { - if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { - state.complete = false; - state.incompletePaths.add(absolutePath); - } - continue; - } - if (entry.isDirectory()) { - if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { - await collectLocalSecretsPlists(root, absolutePath, output, state, depth + 1); - } - } else if (entry.isFile() && entry.name === "LocalSecrets.plist") { - if (await pathIsSafelyWithinIOSRoot(root, absolutePath)) { - output.push(absolutePath); - } else { - state.complete = false; - state.incompletePaths.add(absolutePath); - } - } - } -} - -async function readPublishableKeyCandidates( - root: string, - selection: IOSTargetSelection, - targetLocalSecretsPaths: string[], - schemeRoots: string[], - inlineCandidates: PublishableKeyCandidate[], -): Promise<{ - candidates: PublishableKeyCandidate[]; - schemeDiscoveryComplete: boolean; - schemeDiscoveryEvidence: IOSSourceEvidence[]; -}> { - const selectedProjectDirectory = - selection.state === "selected" ? dirname(resolve(root, selection.projectPath)) : root; - const projectDirectories = [...new Set([selectedProjectDirectory, root])]; - const schemeDiscovery = await schemePublishableKeyCandidates(root, selection, schemeRoots); - const candidates: PublishableKeyCandidate[] = [ - ...inlineCandidates, - ...schemeDiscovery.candidates, - ]; - - for (const directory of projectDirectories) { - for (const [fileName, priority] of [ - [".env.local", 20], - [".env", 30], - ] as const) { - const path = resolve(directory, fileName); - if (!(await pathIsSafelyWithinIOSRoot(root, path))) continue; - const file = Bun.file(path); - if (!(await file.exists()) || file.size > 1_000_000) continue; - try { - for (const line of parseEnvFile(await file.text())) { - if (line.type === "entry" && line.key === "CLERK_PUBLISHABLE_KEY" && line.value) { - candidates.push({ - kind: "environment-file", - value: line.value, - source: relativeIOSPath(root, path), - evidence: [{ path: relativeIOSPath(root, path), keyPath: line.key }], - priority, - }); - } - } - } catch { - // A partially-written env file is not evidence of a usable key. - } - } - } - - for (const path of targetLocalSecretsPaths) { - const file = Bun.file(path); - if (!(await file.exists()) || file.size > 1_000_000) continue; - try { - const parsed = parseIOSPlist(await file.text()); - const value = isRecord(parsed) ? asString(parsed.CLERK_PUBLISHABLE_KEY) : undefined; - if (value) { - candidates.push({ - kind: "local-secrets-plist", - value, - source: relativeIOSPath(root, path), - evidence: [{ path: relativeIOSPath(root, path), keyPath: "CLERK_PUBLISHABLE_KEY" }], - priority: 10, - }); - } - } catch { - candidates.push({ - kind: "local-secrets-plist", - invalid: true, - source: relativeIOSPath(root, path), - evidence: [{ path: relativeIOSPath(root, path), keyPath: "CLERK_PUBLISHABLE_KEY" }], - priority: 10, - }); - } - } - - for (const directory of projectDirectories) { - const path = resolve(directory, ".clerk", ".tmp", "keyless.json"); - if (!(await pathIsSafelyWithinIOSRoot(root, path))) continue; - const file = Bun.file(path); - if (!(await file.exists()) || file.size > 1_000_000) continue; - try { - const parsed: unknown = await file.json(); - const value = isRecord(parsed) ? asString(parsed.publishableKey) : undefined; - if (value) { - candidates.push({ - kind: "keyless-file", - value, - source: relativeIOSPath(root, path), - evidence: [{ path: relativeIOSPath(root, path), keyPath: "publishableKey" }], - priority: 40, - }); - } - } catch { - // A partially-written SDK keyless file is not evidence of a usable key. - } - } - - const ambient = process.env.CLERK_PUBLISHABLE_KEY; - if (ambient) { - candidates.push({ - kind: "ambient-environment", - value: ambient, - source: "CLERK_PUBLISHABLE_KEY environment variable", - evidence: [], - priority: 50, - ambient: true, - }); - } - - return { - candidates: candidates.sort( - (a, b) => a.priority - b.priority || a.source.localeCompare(b.source), - ), - schemeDiscoveryComplete: schemeDiscovery.complete, - schemeDiscoveryEvidence: schemeDiscovery.incompleteEvidence, - }; -} - -async function inspectLocalPublishableKeys( - root: string, - selection: IOSTargetSelection, - targetLocalSecretsPaths: string[], - localSecretsDiscoveryComplete: boolean, - localSecretsDiscoveryEvidence: IOSSourceEvidence[], - schemeRoots: string[], - containerDiscoveryComplete: boolean, - inlineCandidates: PublishableKeyCandidate[], - preferredKind: PublishableKeyCandidate["kind"] | undefined, +function inspectInlinePublishableKey( + target: IOSAppTarget | undefined, diagnostics: IOSDiagnostic[], -): Promise { - const { candidates, schemeDiscoveryComplete, schemeDiscoveryEvidence } = - await readPublishableKeyCandidates( - root, - selection, - targetLocalSecretsPaths, - schemeRoots, - inlineCandidates, - ); - const candidateSources = [...new Set(candidates.map((candidate) => candidate.source))].sort(); - const decodedCandidates: Array<{ - candidate: PublishableKeyCandidate; - decoded?: { frontendApiHost: string; instanceType: "development" | "production" }; - }> = []; - const invalidSources = new Set(); - for (const candidate of candidates) { - if (candidate.decoded) { - decodedCandidates.push({ candidate, decoded: candidate.decoded }); - continue; - } - try { - if (candidate.invalid || candidate.value == null) throw new Error("invalid candidate"); - const value = decodePublishableKey(candidate.value); - decodedCandidates.push({ - candidate, - decoded: { frontendApiHost: value.fapiHost, instanceType: value.instanceType }, - }); - } catch { - invalidSources.add(candidate.source); - decodedCandidates.push({ candidate }); - diagnostics.push({ - code: "clerk.invalid-publishable-key", - severity: "warning", - message: `A publishable key candidate from ${candidate.source} has an invalid format.`, - remedy: "Replace it with a valid pk_test_ or pk_live_ publishable key.", - evidence: candidate.evidence, - }); - } - } - - const localCandidates = decodedCandidates.filter((item) => !item.candidate.ambient); - const ambientCandidates = decodedCandidates.filter((item) => item.candidate.ambient); - const runSchemeCouldBeEffective = - (!schemeDiscoveryComplete || !containerDiscoveryComplete) && - preferredKind !== "inline-literal" && - preferredKind !== "local-secrets-plist"; - const localSecretsCouldBeEffective = - !localSecretsDiscoveryComplete && - preferredKind !== "inline-literal" && - preferredKind !== "run-scheme"; - if (runSchemeCouldBeEffective) { - diagnostics.push({ - code: "xcode.incomplete-scheme-discovery", - severity: "warning", - message: - "Xcode container or Run-scheme discovery was incomplete, so Clerk could not prove the selected target's runtime publishable key.", - remedy: - "Make the selected Xcode project and workspace scheme directories readable, reduce excessive scheme nesting or count, and rerun the command.", - evidence: [ - ...(!containerDiscoveryComplete ? [{ path: "." }] : []), - ...schemeDiscoveryEvidence, - ], - }); - } - if (localSecretsCouldBeEffective) { - const targetDescription = selection.state === "selected" ? ` for ${selection.targetName}` : ""; - diagnostics.push({ - code: "xcode.incomplete-local-secrets-discovery", - severity: "warning", - message: `LocalSecrets.plist discovery was incomplete${targetDescription}, so Clerk could not prove the selected target's runtime publishable key.`, - remedy: - "Make the selected target's synchronized folders readable, remove unsafe symlinks, or reduce excessive LocalSecrets.plist nesting or count, and rerun the command.", - evidence: localSecretsDiscoveryEvidence, - }); - } - if (runSchemeCouldBeEffective || localSecretsCouldBeEffective) { +): IOSProjectInspectionResult["localPublishableKey"] { + if (!target?.swift.evidenceComplete) { return { evidenceComplete: false, found: false, conflict: false, - candidateSources, - invalidSources: [...invalidSources].sort(), - }; - } - // Proven app-init wiring determines which class of candidate can reach the - // selected target. Discovery remains exhaustive and redacted for reporting. - const effectivePriority = localCandidates[0]?.candidate.priority; - const effectiveCandidates = preferredKind - ? decodedCandidates.filter((item) => item.candidate.kind === preferredKind) - : effectivePriority == null - ? ambientCandidates - : localCandidates.filter((item) => item.candidate.priority === effectivePriority); - - // A non-empty higher-precedence source is what the app/CLI will consume. - // Never fall through to a lower-precedence valid key when that source is malformed. - if (effectiveCandidates.some((item) => !item.decoded)) { - return { - evidenceComplete: true, - found: false, - source: effectiveCandidates[0]!.candidate.source, - conflict: false, - candidateSources, - invalidSources: [...invalidSources].sort(), + candidateSources: [], + invalidSources: [], }; } - const effectiveValid = effectiveCandidates.filter( - ( - item, - ): item is typeof item & { - decoded: { frontendApiHost: string; instanceType: "development" | "production" }; - } => item.decoded != null, - ); - const identities = new Set( - effectiveValid.map((item) => `${item.decoded.instanceType}:${item.decoded.frontendApiHost}`), - ); - const effective = effectiveValid[0]; + const calls = target.swift.configureCalls; + const inlineCalls = calls.filter((call) => call.publishableKeyWiring === "inline-literal"); + const candidateSources = [...new Set(inlineCalls.map((call) => call.path))].sort(); - if (identities.size > 1) { - diagnostics.push({ - code: "clerk.conflicting-publishable-keys", - severity: "error", - message: "Equally effective publishable-key sources point to different Clerk instances.", - remedy: "Remove the stale source or make the equally preferred values agree.", - evidence: effectiveValid.flatMap((item) => item.candidate.evidence), - }); + // Only the documented, single startup literal proves which Clerk instance + // the selected target runs against. Every other expression is custom and is + // intentionally preserved without inspecting its source or value. + if ( + calls.length !== 1 || + inlineCalls.length !== 1 || + inlineCalls[0]?.startupBinding !== "app-init" + ) { return { evidenceComplete: true, - found: true, - source: effective!.candidate.source, - conflict: true, + found: false, + conflict: false, candidateSources, - invalidSources: [...invalidSources].sort(), + invalidSources: [], }; } - if (!effective) { + const call = inlineCalls[0]; + if (!call || call.inlinePublishableKey?.state !== "valid") { + const source = call?.path; + if (source) { + diagnostics.push({ + code: "clerk.invalid-publishable-key", + severity: "warning", + message: `The inline publishable key in ${source} has an invalid format.`, + remedy: "Replace it with a valid pk_test_ or pk_live_ publishable key.", + evidence: [{ path: source, keyPath: "Clerk.configure(publishableKey:)" }], + }); + } return { evidenceComplete: true, found: false, + ...(source ? { source } : {}), conflict: false, candidateSources, - invalidSources: [...invalidSources].sort(), + invalidSources: source ? [source] : [], }; } + return { evidenceComplete: true, found: true, + source: call.path, + frontendApiHost: call.inlinePublishableKey.frontendApiHost, + instanceType: call.inlinePublishableKey.instanceType, conflict: false, - source: effective.candidate.source, - frontendApiHost: effective.decoded.frontendApiHost, - instanceType: effective.decoded.instanceType, candidateSources, - invalidSources: [...invalidSources].sort(), + invalidSources: [], }; } -function preferredRuntimeKeyCandidateKind( - target: IOSAppTarget | undefined, -): PublishableKeyCandidate["kind"] | undefined { - if (!target?.swift.evidenceComplete) return undefined; - const startupCalls = target.swift.configureCalls.filter( - (call) => call.startupBinding === "app-init", - ); - if (startupCalls.length !== 1) return undefined; - - const call = startupCalls[0]!; - if (call.publishableKeyWiring === "inline-literal") return "inline-literal"; - if ( - call.publishableKeyWiring === "local-secrets-loader" && - call.localSecretsRuntimeBinding === "proven" - ) { - return "local-secrets-plist"; - } - if (call.publishableKeyWiring === "process-info-environment") return "run-scheme"; - return undefined; -} - -function addUnconsumedRuntimeKeyDiagnostics( - target: IOSAppTarget | undefined, - localPublishableKey: IOSProjectInspectionResult["localPublishableKey"], - diagnostics: IOSDiagnostic[], -): void { - if (!target) return; - - if ( - target.runtimeKeySinks.length > 0 && - !hasIOSProvenStartupKeyWiring(target, "local-secrets-loader") - ) { - diagnostics.push({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - message: - "Clerk found a target-owned LocalSecrets.plist but could not prove that the selected app consumes it at startup.", - remedy: - "Clerk will leave this file unchanged. Connect it from Clerk.configure in the selected @main app's init(), or remove it if it is stale.", - evidence: target.runtimeKeySinks.map((sink) => ({ - path: sink.path, - keyPath: "CLERK_PUBLISHABLE_KEY", - })), - }); - } - - const schemeSources = localPublishableKey.candidateSources.filter((source) => - source.endsWith(".xcscheme"), - ); - if ( - schemeSources.length > 0 && - !hasIOSProvenStartupKeyWiring(target, "process-info-environment") - ) { - diagnostics.push({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - message: - "Clerk found a selected-target Run-scheme key but could not prove that the selected app consumes it at startup.", - remedy: - "Clerk will leave this scheme unchanged. Read CLERK_PUBLISHABLE_KEY from ProcessInfo in Clerk.configure in the selected @main app's init(), or remove the variable if it is stale.", - evidence: schemeSources.map((path) => ({ path, keyPath: "CLERK_PUBLISHABLE_KEY" })), - }); - } -} - async function inspectPackageReferences( root: string, projectPath: string, @@ -1147,93 +598,6 @@ function synchronizedPathIsExcluded(path: string, excluded: Set): boolea ); } -async function localSecretsForTarget(options: { - root: string; - projectPath: string; - groupRootDirectory: string; - targetId: string; - targetObject: PbxObject; - objects: PbxObjects; - parents: Map; -}): Promise { - const { root, projectPath, groupRootDirectory, targetId, targetObject, objects, parents } = - options; - const projectDirectory = dirname(projectPath); - const paths = new Set(); - const state = { complete: true, incompletePaths: new Set() }; - const graphEvidence: IOSSourceEvidence[] = []; - const projectFileEvidence = (objectId: string): IOSSourceEvidence => ({ - path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), - objectId, - }); - const resourcePhaseIds = new Set( - asStringArray(targetObject.buildPhases).filter( - (phaseId) => objects[phaseId]?.isa === "PBXResourcesBuildPhase", - ), - ); - - for (const phaseId of resourcePhaseIds) { - const phase = objects[phaseId]; - if (phase?.isa !== "PBXResourcesBuildPhase") continue; - for (const buildFileId of asStringArray(phase.files)) { - const buildFile = objects[buildFileId]; - if (!buildFile || !buildFileIOSApplicability(buildFile).applies) continue; - const fileReference = asString(buildFile.fileRef); - if (!fileReference) continue; - const absolutePath = resolvePbxFilePath( - fileReference, - objects, - parents, - projectDirectory, - groupRootDirectory, - ); - if (absolutePath?.endsWith(`${sep}LocalSecrets.plist`)) { - if (await pathIsSafelyWithinIOSRoot(root, absolutePath)) { - paths.add(absolutePath); - } else { - state.complete = false; - state.incompletePaths.add(absolutePath); - } - } - } - } - - for (const groupId of asStringArray(targetObject.fileSystemSynchronizedGroups)) { - const group = objects[groupId]; - if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") continue; - const groupPath = resolvePbxFilePath( - groupId, - objects, - parents, - projectDirectory, - groupRootDirectory, - ); - if (!groupPath || !(await pathIsSafelyWithinIOSRoot(root, groupPath))) { - state.complete = false; - if (groupPath) state.incompletePaths.add(groupPath); - else graphEvidence.push(projectFileEvidence(groupId)); - continue; - } - - const discovered: string[] = []; - await collectLocalSecretsPlists(root, groupPath, discovered, state); - const excluded = synchronizedExclusions(group, targetId, resourcePhaseIds, objects); - for (const absolutePath of discovered) { - const relativePath = relative(groupPath, absolutePath).split(sep).join("/"); - if (!synchronizedPathIsExcluded(relativePath, excluded)) paths.add(absolutePath); - } - } - - return { - paths: [...paths].sort(), - complete: state.complete, - evidence: [ - ...[...state.incompletePaths].sort().map((path) => ({ path: relativeIOSPath(root, path) })), - ...graphEvidence, - ], - }; -} - async function collectSwiftFiles( root: string, directory: string, @@ -1673,15 +1037,6 @@ async function parseProject( }); } - const targetLocalSecrets = await localSecretsForTarget({ - root, - projectPath, - groupRootDirectory, - targetId, - targetObject, - objects, - parents, - }); const appTarget: IOSAppTarget = { id: targetId, name: targetName, @@ -1698,12 +1053,7 @@ async function parseProject( diagnostics, ), swift: swiftInspection, - runtimeKeySinks: targetLocalSecrets.paths.map((path) => ({ - kind: "local-secrets-plist" as const, - path: relativeIOSPath(root, path), - })), }; - localSecretsDiscoveryByTarget.set(appTarget, targetLocalSecrets); appTargets.push(appTarget); } @@ -1952,42 +1302,7 @@ export async function inspectIOSProject( target.id === selection.targetId && target.projectPath === selection.projectPath, ) : undefined; - const inlinePublishableKeyCandidates: PublishableKeyCandidate[] = - selectedAppTarget?.swift.configureCalls - .filter((call) => call.publishableKeyWiring === "inline-literal") - .map((call) => ({ - kind: "inline-literal" as const, - source: call.path, - evidence: [{ path: call.path, keyPath: "Clerk.configure(publishableKey:)" }], - priority: 0, - ...(call.inlinePublishableKey?.state === "valid" - ? { - decoded: { - frontendApiHost: call.inlinePublishableKey.frontendApiHost, - instanceType: call.inlinePublishableKey.instanceType, - }, - } - : { invalid: true as const }), - })) ?? []; - const selectedLocalSecretsDiscovery = selectedAppTarget - ? localSecretsDiscoveryByTarget.get(selectedAppTarget) - : undefined; - const localPublishableKeyInspection = await inspectLocalPublishableKeys( - root, - selection, - selectedAppTarget?.runtimeKeySinks.map((sink) => resolve(root, sink.path)) ?? [], - selectedLocalSecretsDiscovery?.complete ?? true, - selectedLocalSecretsDiscovery?.evidence ?? [], - [ - ...(selection.state === "selected" ? [resolve(root, selection.projectPath)] : []), - ...discovered.workspacePaths, - ], - discovered.complete && referencedProjects.complete, - inlinePublishableKeyCandidates, - preferredRuntimeKeyCandidateKind(selectedAppTarget), - diagnostics, - ); - addUnconsumedRuntimeKeyDiagnostics(selectedAppTarget, localPublishableKeyInspection, diagnostics); + const localPublishableKeyInspection = inspectInlinePublishableKey(selectedAppTarget, diagnostics); const result: IOSProjectInspectionResult = { schemaVersion: 1, platform: "ios", diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index e3f208a4b..25618d001 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -14,7 +14,6 @@ function target(): IOSAppTarget { projectPath: "MyApp.xcodeproj", configurations: [], packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, - runtimeKeySinks: [], swift: { sourceFilesScanned: 1, evidenceComplete: true, @@ -22,7 +21,6 @@ function target(): IOSAppTarget { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], - localSecretsRuntimeBindings: [], environmentInjections: [], environmentConsumers: [], authFlowReferences: [], @@ -101,18 +99,15 @@ describe("shouldInstallClerkKitUI", () => { }); describe("direct configuration compatibility", () => { - test.each(["local-secrets-loader", "process-info-environment"] as const)( - "preserves a proven startup %s configure route", - (publishableKeyWiring) => { + test.each(["app-init", "unproven"] as const)( + "preserves a custom configure route with %s placement", + (startupBinding) => { const selected = target(); selected.swift.configureCalls = [ { path: "MyApp/MyAppApp.swift", - publishableKeyWiring, - startupBinding: "app-init", - ...(publishableKeyWiring === "local-secrets-loader" - ? { localSecretsRuntimeBinding: "proven" as const } - : {}), + publishableKeyWiring: "custom", + startupBinding, }, ]; const result = inspection(selected); @@ -122,47 +117,20 @@ describe("direct configuration compatibility", () => { }, ); - test("does not infer compatibility from an enabled selected-target scheme key", () => { + test("plans direct configuration for a documented inline literal", () => { const selected = target(); - const result = inspection(selected); - result.localPublishableKey.candidateSources = [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + selected.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "inline-literal", + inlinePublishableKey: { + state: "valid", + frontendApiHost: "example.clerk.accounts.dev", + instanceType: "development", + }, + startupBinding: "app-init", + }, ]; - - expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); - expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); - }); - - test("does not infer compatibility from a target-owned runtime key sink", () => { - const selected = target(); - selected.runtimeKeySinks = [{ kind: "local-secrets-plist", path: "MyApp/LocalSecrets.plist" }]; - const result = inspection(selected); - - expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); - expect(shouldPlanIOSDirectConfig(result, selected)).toBe(true); - }); - - test.each([ - { - name: "non-startup ProcessInfo", - publishableKeyWiring: "process-info-environment" as const, - startupBinding: "unproven" as const, - }, - { - name: "non-startup LocalSecrets", - publishableKeyWiring: "local-secrets-loader" as const, - startupBinding: "unproven" as const, - localSecretsRuntimeBinding: "proven" as const, - }, - { - name: "unproven LocalSecrets loader", - publishableKeyWiring: "local-secrets-loader" as const, - startupBinding: "app-init" as const, - localSecretsRuntimeBinding: "unproven" as const, - }, - ])("does not preserve $name wiring", (configureCall) => { - const selected = target(); - selected.swift.configureCalls = [{ path: "MyApp/Auth.swift", ...configureCall }]; const result = inspection(selected); expect(hasIOSDirectConfigCompatibility(result, selected)).toBe(false); diff --git a/packages/cli-core/src/commands/init/ios/products.ts b/packages/cli-core/src/commands/init/ios/products.ts index 449f7f5dc..ab0f6dd9d 100644 --- a/packages/cli-core/src/commands/init/ios/products.ts +++ b/packages/cli-core/src/commands/init/ios/products.ts @@ -22,36 +22,21 @@ export function shouldInstallClerkKitUI(target: IOSAppTarget): boolean { return clerkKitUIInstallDecision(target) === "prebuilt"; } -export function hasIOSProvenStartupKeyWiring( - target: IOSAppTarget, - wiring: "local-secrets-loader" | "process-info-environment", -): boolean { - return target.swift.configureCalls.some( - (call) => - call.startupBinding === "app-init" && - call.publishableKeyWiring === wiring && - (wiring !== "local-secrets-loader" || call.localSecretsRuntimeBinding === "proven"), - ); -} - -/** Existing runtime-key routes that direct source configuration must preserve. */ +/** Existing custom runtime-key routes that direct source configuration must preserve. */ export function hasIOSDirectConfigCompatibility( inspection: IOSProjectInspectionResult, target: IOSAppTarget, ): boolean { - // Preserve the inspection parameter for callers that make this decision - // from a complete inspection, but never infer runtime wiring from artifacts. + // A custom expression belongs to the developer. Its value and loading + // strategy are deliberately not interpreted by the inspector. void inspection; - return ( - hasIOSProvenStartupKeyWiring(target, "local-secrets-loader") || - hasIOSProvenStartupKeyWiring(target, "process-info-environment") - ); + return target.swift.configureCalls.some((call) => call.publishableKeyWiring === "custom"); } /** * Routes only the fresh/direct-literal Swift path to the source mutator. - * Existing LocalSecrets and ProcessInfo integrations remain compatibility - * paths and are never rewritten into a literal automatically. + * Existing custom integrations remain compatibility paths and are never + * rewritten into a literal automatically. */ export function shouldPlanIOSDirectConfig( inspection: IOSProjectInspectionResult, diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 456aee7bc..f91c31536 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -267,39 +267,37 @@ describe("inspectSwiftSources", () => { expect(inspection.configureCalls).toEqual([ { path: "Configuration.swift", - publishableKeyWiring: "local-secrets-loader", + publishableKeyWiring: "custom", startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "local-secrets-loader", + publishableKeyWiring: "custom", startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "process-info-environment", + publishableKeyWiring: "custom", startupBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "unknown", + publishableKeyWiring: "custom", startupBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "unknown", + publishableKeyWiring: "custom", startupBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "process-info-environment", + publishableKeyWiring: "custom", startupBinding: "unproven", }, { path: "Configuration.swift", - publishableKeyWiring: "unknown", + publishableKeyWiring: "custom", startupBinding: "unproven", }, ]); @@ -311,462 +309,6 @@ describe("inspectSwiftSources", () => { expect(serialized).not.toContain("ANALYTICS_KEY"); }); - test("proves only an exact LocalSecrets plist runtime binding", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); - temporaryDirectories.push(root); - const provenPath = join(root, "LocalSecrets.swift"); - const nameOnlyPath = join(root, "NameOnly.swift"); - const configPath = join(root, "App.swift"); - await Bun.write( - provenPath, - `import Foundation - struct QuickstartLocalSecrets { - let publishableKey: String? - let analyticsKey: String? - - static func load(bundle: Bundle = .main) -> QuickstartLocalSecrets { - guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] - else { return .init(publishableKey: nil, analyticsKey: nil) } - return .init( - publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String, - analyticsKey: values["ANALYTICS_KEY"] as? String - ) - } - } - - struct SameFileFakeLocalSecrets { - let publishableKey: String? - static func load(bundle: Bundle) -> SameFileFakeLocalSecrets { - guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] - else { return .init(publishableKey: nil) } - return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) - } - static func unrelatedDefault(bundle: Bundle = .main) { - _ = bundle - } - } - - struct SideEffectOnlyLocalSecrets { - let publishableKey: String? - static func load() -> SideEffectOnlyLocalSecrets { - let values = ApplicationSecrets.values - _ = unusedCanonicalHelper() - return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) - } - static func unusedCanonicalHelper() -> String? { - guard let url = Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] - else { return nil } - return values["CLERK_PUBLISHABLE_KEY"] as? String - } - } - - enum ApplicationSecrets { - static let values: [String: Any] = [:] - } - - struct ShadowedLocalSecrets { - let publishableKey: String? - static func load(bundle: Bundle = .main) -> ShadowedLocalSecrets { - let values = ApplicationSecrets.values - if let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] { - _ = values - } - return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) - } - }`, - ); - await Bun.write( - nameOnlyPath, - `// Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist") - struct FakeLocalSecrets { - static let key = ApplicationSecrets.clerkKey - static let decoy = "CLERK_PUBLISHABLE_KEY" - }`, - ); - await Bun.write( - configPath, - `import ClerkKit - func configureProven() { - Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") - } - func configureExplicitBundle() { - Clerk.configure(publishableKey: QuickstartLocalSecrets.load(bundle: Bundle.module).publishableKey ?? "") - } - func configureWrongReturnedMember() { - Clerk.configure(publishableKey: QuickstartLocalSecrets.load().analyticsKey ?? "") - } - func configureLargerUnrelatedExpression() { - Clerk.configure(publishableKey: ApplicationSecrets.choose(QuickstartLocalSecrets.load().publishableKey)) - } - func configureAlternateFallback() { - Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? ApplicationSecrets.key) - } - func configureSameFileDecoy() { - Clerk.configure(publishableKey: SameFileFakeLocalSecrets.load().publishableKey ?? "") - } - func configureSideEffectOnlyDecoy() { - Clerk.configure(publishableKey: SideEffectOnlyLocalSecrets.load().publishableKey ?? "") - } - func configureShadowedDecoy() { - Clerk.configure(publishableKey: ShadowedLocalSecrets.load().publishableKey ?? "") - } - func configureDecoy() { - Clerk.configure(publishableKey: FakeLocalSecrets.key) - }`, - ); - - const inspection = await inspectSwiftSources([ - { absolutePath: configPath, relativePath: "App.swift" }, - { absolutePath: nameOnlyPath, relativePath: "NameOnly.swift" }, - { absolutePath: provenPath, relativePath: "LocalSecrets.swift" }, - ]); - - expect(inspection.localSecretsRuntimeBindings).toEqual([{ path: "LocalSecrets.swift" }]); - expect(inspection.configureCalls).toEqual([ - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "proven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "unproven", - localSecretsRuntimeBinding: "unproven", - }, - ]); - expect(JSON.stringify(inspection)).not.toContain("CLERK_PUBLISHABLE_KEY"); - }); - - test("rejects unreachable, conditional, alternate, and mutable delegated resolver returns", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); - temporaryDirectories.push(root); - const loaderPath = join(root, "LocalSecrets.swift"); - const configPath = join(root, "App.swift"); - const delegatedLoader = ( - symbol: string, - resolverBody: string, - overrides: { loadBody?: string; normalizerBody?: string; extraMethods?: string } = {}, - ) => ` - struct ${symbol} { - let publishableKey: String? - - static func load( - bundle: Bundle = .main, - processInfo: ProcessInfo = .processInfo - ) -> ${symbol} { - ${ - overrides.loadBody ?? - `let plistValues = localSecretsPlistValues(bundle: bundle) - return .init( - publishableKey: resolveValue( - for: "CLERK_PUBLISHABLE_KEY", - processInfo: processInfo, - plistValues: plistValues - ) - ) - ` - } - } - - static func resolveValue( - for key: String, - processInfo: ProcessInfo, - plistValues: [String: Any] - ) -> String? { - ${resolverBody} - } - - static func normalized(_ value: String?) -> String? { - ${ - overrides.normalizerBody ?? - `guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value` - } - } - - static func localSecretsPlistValues(bundle: Bundle) -> [String: Any] { - guard let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] - else { return [:] } - return values - } - - ${overrides.extraMethods ?? ""} - } - `; - - await Bun.write( - loaderPath, - `import Foundation - ${delegatedLoader( - "CanonicalDelegatedLocalSecrets", - `guard !key.isEmpty else { - return nil - } - if let environmentValue = normalized(processInfo.environment[key]) { - return environmentValue - } - return normalized(plistValues[key] as? String)`, - )} - ${delegatedLoader( - "UnreachableDecoyLocalSecrets", - `if false { - return normalized(plistValues[key] as? String) - } - return ApplicationSecrets.key`, - )} - ${delegatedLoader( - "ConditionalLookupLocalSecrets", - `if ApplicationSecrets.usePlist { - return normalized(plistValues[key] as? String) - } - return ApplicationSecrets.key`, - )} - ${delegatedLoader( - "AlternatePathLocalSecrets", - `if ApplicationSecrets.useAlternate { - return ApplicationSecrets.key - } - return normalized(plistValues[key] as? String)`, - )} - ${delegatedLoader( - "MultipleDirectReturnsLocalSecrets", - `return normalized(plistValues[key] as? String) - return ApplicationSecrets.key`, - )} - ${delegatedLoader( - "ShadowedDictionaryLocalSecrets", - `let plistValues = ApplicationSecrets.values - return normalized(plistValues[key] as? String)`, - )} - ${delegatedLoader( - "ReassignedDictionaryLocalSecrets", - `plistValues[key] = ApplicationSecrets.key - return normalized(plistValues[key] as? String)`, - )} - ${delegatedLoader( - "ClosureLookupLocalSecrets", - `let lookup = { - return normalized(plistValues[key] as? String) - } - return lookup()`, - )} - ${delegatedLoader( - "ArbitraryWrapperLocalSecrets", - `return alternate(plistValues[key] as? String)`, - { - extraMethods: `static func alternate(_ value: String?) -> String? { - ApplicationSecrets.key - }`, - }, - )} - ${delegatedLoader( - "ReassignedHelperValueLocalSecrets", - `return normalized(plistValues[key] as? String)`, - { - loadBody: `var plistValues = localSecretsPlistValues(bundle: bundle) - plistValues = ApplicationSecrets.values - return .init( - publishableKey: resolveValue( - for: "CLERK_PUBLISHABLE_KEY", - processInfo: processInfo, - plistValues: plistValues - ) - )`, - }, - )} - ${delegatedLoader( - "TrustedSideEffectModuleLocalSecrets", - `return normalized(plistValues[key] as? String)`, - { - loadBody: `_ = localSecretsPlistValues(bundle: bundle) - let plistValues = localSecretsPlistValues(bundle: .module) - return .init( - publishableKey: resolveValue( - for: "CLERK_PUBLISHABLE_KEY", - processInfo: processInfo, - plistValues: plistValues - ) - )`, - }, - )} - ${delegatedLoader( - "AlternateNormalizerLocalSecrets", - `return normalized(plistValues[key] as? String)`, - { - normalizerBody: `guard let value else { return nil } - return ApplicationSecrets.key`, - }, - )}`, - ); - await Bun.write( - configPath, - `import ClerkKit - func configureCanonical() { - Clerk.configure(publishableKey: CanonicalDelegatedLocalSecrets.load().publishableKey ?? "") - } - func configureUnreachableDecoy() { - Clerk.configure(publishableKey: UnreachableDecoyLocalSecrets.load().publishableKey ?? "") - } - func configureConditionalLookup() { - Clerk.configure(publishableKey: ConditionalLookupLocalSecrets.load().publishableKey ?? "") - } - func configureAlternatePath() { - Clerk.configure(publishableKey: AlternatePathLocalSecrets.load().publishableKey ?? "") - } - func configureMultipleReturns() { - Clerk.configure(publishableKey: MultipleDirectReturnsLocalSecrets.load().publishableKey ?? "") - } - func configureShadowedDictionary() { - Clerk.configure(publishableKey: ShadowedDictionaryLocalSecrets.load().publishableKey ?? "") - } - func configureReassignedDictionary() { - Clerk.configure(publishableKey: ReassignedDictionaryLocalSecrets.load().publishableKey ?? "") - } - func configureClosureLookup() { - Clerk.configure(publishableKey: ClosureLookupLocalSecrets.load().publishableKey ?? "") - } - func configureArbitraryWrapper() { - Clerk.configure(publishableKey: ArbitraryWrapperLocalSecrets.load().publishableKey ?? "") - } - func configureReassignedHelperValue() { - Clerk.configure(publishableKey: ReassignedHelperValueLocalSecrets.load().publishableKey ?? "") - } - func configureTrustedSideEffectModule() { - Clerk.configure(publishableKey: TrustedSideEffectModuleLocalSecrets.load().publishableKey ?? "") - } - func configureAlternateNormalizer() { - Clerk.configure(publishableKey: AlternateNormalizerLocalSecrets.load().publishableKey ?? "") - }`, - ); - - const inspection = await inspectSwiftSources([ - { absolutePath: configPath, relativePath: "App.swift" }, - { absolutePath: loaderPath, relativePath: "LocalSecrets.swift" }, - ]); - - expect(inspection.configureCalls.map((call) => call.localSecretsRuntimeBinding)).toEqual([ - "proven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - "unproven", - ]); - expect(inspection.localSecretsRuntimeBindings).toEqual([{ path: "LocalSecrets.swift" }]); - }); - - test("does not prove a LocalSecrets loader under conditional compilation", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); - temporaryDirectories.push(root); - const path = join(root, "App.swift"); - await Bun.write( - path, - `import ClerkKit - import Foundation - - #if DEBUG - struct ConditionalLocalSecrets { - let publishableKey: String? - static func load() -> ConditionalLocalSecrets { - guard let url = Bundle.main.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = plist as? [String: Any] - else { return .init(publishableKey: nil) } - return .init(publishableKey: values["CLERK_PUBLISHABLE_KEY"] as? String) - } - } - #endif - - @main struct AppMain: App { - init() { - Clerk.configure(publishableKey: ConditionalLocalSecrets.load().publishableKey ?? "") - } - }`, - ); - - const inspection = await inspectSwiftSources([ - { absolutePath: path, relativePath: "App.swift" }, - ]); - - expect(inspection.localSecretsRuntimeBindings).toEqual([]); - expect(inspection.configureCalls).toEqual([ - { - path: "App.swift", - publishableKeyWiring: "local-secrets-loader", - startupBinding: "app-init", - localSecretsRuntimeBinding: "unproven", - }, - ]); - }); - test("proves only a direct call in the @main type's init as startup-bound", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index a40b0916d..5760a5072 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -198,10 +198,6 @@ export function sanitizeSwiftSourceWithStatus(source: string): SwiftSourceSaniti return sanitizeSwift(source, true); } -function sourceWithoutComments(source: string): SwiftSourceSanitization { - return sanitizeSwift(source, false); -} - function has(source: string, pattern: RegExp): boolean { pattern.lastIndex = 0; return pattern.test(source); @@ -211,1148 +207,6 @@ const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; -function topLevelCommaSeparated(source: string): string[] { - const segments: string[] = []; - let start = 0; - let parenthesisDepth = 0; - let bracketDepth = 0; - let braceDepth = 0; - for (let index = 0; index < source.length; index++) { - const character = source[index]; - if (character === "(") parenthesisDepth++; - if (character === ")") parenthesisDepth--; - if (character === "[") bracketDepth++; - if (character === "]") bracketDepth--; - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - if (character === "," && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { - segments.push(source.slice(start, index)); - start = index + 1; - } - } - segments.push(source.slice(start)); - return segments.map((segment) => segment.trim()).filter(Boolean); -} - -interface DirectStaticMethodEvidence { - name: string; - parameters: string; - header: string; - openingBrace: number; - closingBrace: number; -} - -interface BundleParameterEvidence { - externalName: string; - localName: string; - defaultsToMain: boolean; -} - -function directStaticMethods( - source: string, - typeOpeningBrace: number, - typeClosingBrace: number, -): DirectStaticMethodEvidence[] { - const methods: DirectStaticMethodEvidence[] = []; - const methodPattern = /\bstatic\s+func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g; - methodPattern.lastIndex = typeOpeningBrace + 1; - let method: RegExpExecArray | null; - - while ((method = methodPattern.exec(source)) !== null && method.index < typeClosingBrace) { - const name = method[1]; - if (!name || braceDepthAt(source, typeOpeningBrace, method.index) !== 1) continue; - const openingParenthesis = source.indexOf("(", method.index); - const closingParenthesis = matchingParenthesis(source, openingParenthesis); - if (closingParenthesis == null || closingParenthesis >= typeClosingBrace) continue; - const parameters = source.slice(openingParenthesis + 1, closingParenthesis); - - const methodOpeningBrace = source.indexOf("{", closingParenthesis + 1); - if (methodOpeningBrace === -1 || methodOpeningBrace >= typeClosingBrace) continue; - const methodHeader = source.slice(closingParenthesis + 1, methodOpeningBrace); - if ( - /[;}]/.test(methodHeader) || - /\bfunc\b/.test(methodHeader) || - braceDepthAt(source, typeOpeningBrace, methodOpeningBrace) !== 1 - ) { - continue; - } - const methodClosingBrace = matchingBrace(source, methodOpeningBrace); - if (methodClosingBrace == null || methodClosingBrace > typeClosingBrace) continue; - - methods.push({ - name, - parameters, - header: methodHeader, - openingBrace: methodOpeningBrace, - closingBrace: methodClosingBrace, - }); - methodPattern.lastIndex = methodClosingBrace + 1; - } - - return methods; -} - -function bundleParameters(parameters: string): BundleParameterEvidence[] { - return topLevelCommaSeparated(parameters).flatMap((parameter) => { - const match = - /^(?:(_|[A-Za-z_][A-Za-z0-9_]*)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*Bundle\b([\s\S]*)$/.exec( - parameter, - ); - const localName = match?.[2]; - if (!localName) return []; - return [ - { - externalName: match[1] ?? localName, - localName, - defaultsToMain: /=\s*(?:Bundle\s*)?\.\s*main\b/.test(match[3] ?? ""), - }, - ]; - }); -} - -function directZeroArgumentLoadEvidence( - methods: DirectStaticMethodEvidence[], -): DirectStaticMethodEvidence | undefined { - const candidates = methods.filter((method) => { - if (method.name !== "load" || /\b(?:async|throws|rethrows)\b/.test(method.header)) { - return false; - } - return topLevelCommaSeparated(method.parameters).every((parameter) => parameter.includes("=")); - }); - return candidates.length === 1 ? candidates[0] : undefined; -} - -function escapeRegularExpression(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function directCallArguments( - source: string, - caller: DirectStaticMethodEvidence, - typeSymbol: string, - calleeName: string, -): string[] { - const bodyStart = caller.openingBrace + 1; - const body = source.slice(bodyStart, caller.closingBrace); - const escapedType = escapeRegularExpression(typeSymbol); - const escapedCallee = escapeRegularExpression(calleeName); - const pattern = new RegExp( - `(?:^|[^A-Za-z0-9_.])(?:(?:Self|${escapedType})\\s*\\.\\s*)?${escapedCallee}\\s*\\(`, - "g", - ); - const argumentsList: string[] = []; - let call: RegExpExecArray | null; - - while ((call = pattern.exec(body)) !== null) { - const localOpeningParenthesis = call.index + call[0].lastIndexOf("("); - const openingParenthesis = bodyStart + localOpeningParenthesis; - if (braceDepthAt(source, caller.openingBrace, openingParenthesis) !== 1) continue; - const closingParenthesis = matchingParenthesis(source, openingParenthesis); - if (closingParenthesis == null || closingParenthesis >= caller.closingBrace) continue; - argumentsList.push(source.slice(openingParenthesis + 1, closingParenthesis)); - pattern.lastIndex = closingParenthesis - bodyStart + 1; - } - - return argumentsList; -} - -function reachableStaticMethods( - source: string, - typeSymbol: string, - methods: DirectStaticMethodEvidence[], - root: DirectStaticMethodEvidence, -): DirectStaticMethodEvidence[] | undefined { - const methodsByName = new Map(); - for (const method of methods) { - const existing = methodsByName.get(method.name) ?? []; - existing.push(method); - methodsByName.set(method.name, existing); - } - - const reachable = new Map([[root.openingBrace, root]]); - const pending = [root]; - while (pending.length > 0) { - const caller = pending.shift(); - if (!caller) break; - const callerBody = source.slice(caller.openingBrace + 1, caller.closingBrace); - for (const [name, candidates] of methodsByName) { - const calls = directCallArguments(source, caller, typeSymbol, name); - if (calls.length === 0) continue; - if ( - new RegExp(`\\b(?:let|var|func)\\s+${escapeRegularExpression(name)}\\b`).test(callerBody) - ) { - return undefined; - } - if (candidates.length !== 1) return undefined; - const callee = candidates[0]; - if (callee && !reachable.has(callee.openingBrace)) { - reachable.set(callee.openingBrace, callee); - pending.push(callee); - } - } - } - return [...reachable.values()]; -} - -function localDeclarationPositions( - source: string, - method: DirectStaticMethodEvidence, - name: string, -): number[] { - const bodyStart = method.openingBrace + 1; - const body = source.slice(bodyStart, method.closingBrace); - const pattern = new RegExp(`\\b(?:let|var)\\s+${escapeRegularExpression(name)}\\b`, "g"); - return [...body.matchAll(pattern)].map((match) => bodyStart + match.index); -} - -function hasUniqueDirectImmutableLocalDeclaration( - source: string, - method: DirectStaticMethodEvidence, - name: string, -): boolean { - const bodyStart = method.openingBrace + 1; - const body = source.slice(bodyStart, method.closingBrace); - const declarationPattern = new RegExp(`\\b(let|var)\\s+${escapeRegularExpression(name)}\\b`, "g"); - const declarations = [...body.matchAll(declarationPattern)]; - return ( - declarations.length === 1 && - declarations[0]?.[1] === "let" && - declarations[0].index != null && - braceDepthAt(source, method.openingBrace, bodyStart + declarations[0].index) === 1 - ); -} - -function hasUniqueImmutableInitializedLocal( - source: string, - method: DirectStaticMethodEvidence, - name: string, -): boolean { - if (!hasUniqueDirectImmutableLocalDeclaration(source, method, name)) return false; - const body = source.slice(method.openingBrace + 1, method.closingBrace); - const escapedName = escapeRegularExpression(name); - const declarationsWithInitializers = [ - ...body.matchAll(new RegExp(`\\blet\\s+${escapedName}(?:\\s*:[^=,;{}\\n\\r]+)?\\s*=`, "g")), - ]; - if (declarationsWithInitializers.length !== 1) return false; - - // An immutable declaration may only receive its declaration initializer. - // Property/subscript writes and mutating collection operations make the - // value reaching the key lookup impossible to prove with this scanner. - const writes = [ - ...body.matchAll( - new RegExp( - `\\b${escapedName}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)?\\s*=(?!=)`, - "g", - ), - ), - ]; - if (writes.length !== 1) return false; - return !new RegExp( - `\\b${escapedName}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, - ).test(body); -} - -function hasParameterMutationOrShadowing( - source: string, - method: DirectStaticMethodEvidence, - parameter: string, -): boolean { - if (localDeclarationPositions(source, method, parameter).length > 0) return true; - const body = source.slice(method.openingBrace + 1, method.closingBrace); - const escapedParameter = escapeRegularExpression(parameter); - return ( - new RegExp( - `\\b${escapedParameter}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)?\\s*=(?!=)`, - ).test(body) || - new RegExp( - `\\b${escapedParameter}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, - ).test(body) - ); -} - -interface ExactMainBundleResourceEvidence { - urlVariablesByMethod: Map>; -} - -function exactMainBundleResourceEvidence( - structuralSource: string, - valueSource: string, - typeSymbol: string, - reachableMethods: DirectStaticMethodEvidence[], - loadMethod: DirectStaticMethodEvidence, -): ExactMainBundleResourceEvidence { - const trustedBundleParameters = new Map>([ - [ - loadMethod.openingBrace, - new Set( - bundleParameters(loadMethod.parameters) - .filter((parameter) => parameter.defaultsToMain) - .map((parameter) => parameter.localName), - ), - ], - ]); - const methodsByName = new Map(reachableMethods.map((method) => [method.name, method])); - - let changed = true; - while (changed) { - changed = false; - for (const caller of reachableMethods) { - const trustedCallerNames = new Set( - [...(trustedBundleParameters.get(caller.openingBrace) ?? [])].filter( - (name) => localDeclarationPositions(structuralSource, caller, name).length === 0, - ), - ); - for (const [calleeName, callee] of methodsByName) { - const calleeBundleParameters = bundleParameters(callee.parameters); - if (calleeBundleParameters.length === 0) continue; - for (const callArguments of directCallArguments( - structuralSource, - caller, - typeSymbol, - calleeName, - )) { - const argumentSegments = topLevelCommaSeparated(callArguments); - for (const parameter of calleeBundleParameters) { - if (parameter.externalName === "_") continue; - const escapedLabel = escapeRegularExpression(parameter.externalName); - const trustedArgument = argumentSegments.some((argument) => { - const label = new RegExp(`^${escapedLabel}\\s*:\\s*([A-Za-z_][A-Za-z0-9_]*)$`).exec( - argument, - ); - return label?.[1] != null && trustedCallerNames.has(label[1]); - }); - const literalMainArgument = argumentSegments.some((argument) => - new RegExp(`^${escapedLabel}\\s*:\\s*(?:Bundle\\s*)?\\.\\s*main$`).test(argument), - ); - if (!trustedArgument && !literalMainArgument) continue; - const trustedCalleeNames = - trustedBundleParameters.get(callee.openingBrace) ?? new Set(); - if (!trustedCalleeNames.has(parameter.localName)) { - trustedCalleeNames.add(parameter.localName); - trustedBundleParameters.set(callee.openingBrace, trustedCalleeNames); - changed = true; - } - } - } - } - } - } - - const urlVariablesByMethod = new Map>(); - for (const method of reachableMethods) { - const body = valueSource.slice(method.openingBrace + 1, method.closingBrace); - const urlVariables = new Set(); - for (const match of body.matchAll( - /\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Bundle\s*\.\s*main\s*\.\s*url\s*\(\s*forResource\s*:\s*"LocalSecrets"\s*,\s*withExtension\s*:\s*"plist"/g, - )) { - if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { - urlVariables.add(match[1]); - } - } - for (const variable of trustedBundleParameters.get(method.openingBrace) ?? []) { - if (localDeclarationPositions(structuralSource, method, variable).length > 0) continue; - const pattern = new RegExp( - `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapeRegularExpression(variable)}\\s*\\.\\s*url\\s*\\(\\s*forResource\\s*:\\s*"LocalSecrets"\\s*,\\s*withExtension\\s*:\\s*"plist"`, - "g", - ); - for (const match of body.matchAll(pattern)) { - if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { - urlVariables.add(match[1]); - } - } - } - if (urlVariables.size > 0) urlVariablesByMethod.set(method.openingBrace, urlVariables); - } - return { urlVariablesByMethod }; -} - -interface RedactedExpressionEvidence { - structural: string; - value: string; -} - -interface NamedParameterEvidence { - externalName: string; - localName: string; -} - -function namedParameters(parameters: string): NamedParameterEvidence[] { - return topLevelCommaSeparated(parameters).flatMap((parameter) => { - const match = /^(?:(_|[A-Za-z_][A-Za-z0-9_]*)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*:/.exec(parameter); - const localName = match?.[2]; - if (!localName) return []; - return [{ externalName: match[1] ?? localName, localName }]; - }); -} - -function isTopLevelPosition(source: string, position: number): boolean { - let parenthesisDepth = 0; - let bracketDepth = 0; - let braceDepth = 0; - for (let index = 0; index < position; index++) { - const character = source[index]; - if (character === "(") parenthesisDepth++; - if (character === ")") parenthesisDepth--; - if (character === "[") bracketDepth++; - if (character === "]") bracketDepth--; - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - } - return parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0; -} - -function topLevelLabeledExpression( - structuralArguments: string, - valueArguments: string, - label: string, -): RedactedExpressionEvidence | undefined { - const labelPattern = new RegExp(`\\b${escapeRegularExpression(label)}\\s*:`, "g"); - const expressions: RedactedExpressionEvidence[] = []; - let match: RegExpExecArray | null; - - while ((match = labelPattern.exec(structuralArguments)) !== null) { - if (!isTopLevelPosition(structuralArguments, match.index)) continue; - const start = match.index + match[0].length; - let end = structuralArguments.length; - let parenthesisDepth = 0; - let bracketDepth = 0; - let braceDepth = 0; - for (let index = start; index < structuralArguments.length; index++) { - const character = structuralArguments[index]; - if (character === "(") parenthesisDepth++; - if (character === ")") parenthesisDepth--; - if (character === "[") bracketDepth++; - if (character === "]") bracketDepth--; - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - if (character === "," && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { - end = index; - break; - } - } - expressions.push({ - structural: structuralArguments.slice(start, end), - value: valueArguments.slice(start, end), - }); - } - - return expressions.length === 1 ? expressions[0] : undefined; -} - -function returnedPublishableKeyExpression( - structuralSource: string, - valueSource: string, - typeSymbol: string, - loadMethod: DirectStaticMethodEvidence, -): RedactedExpressionEvidence | undefined { - const bodyStart = loadMethod.openingBrace + 1; - const structuralBody = structuralSource.slice(bodyStart, loadMethod.closingBrace); - const escapedType = escapeRegularExpression(typeSymbol); - const returnPattern = new RegExp(`\\breturn\\s+(?:\\.\\s*init|${escapedType})\\s*\\(`, "g"); - const expressions: RedactedExpressionEvidence[] = []; - let returnedInitializer: RegExpExecArray | null; - - while ((returnedInitializer = returnPattern.exec(structuralBody)) !== null) { - const returnIndex = bodyStart + returnedInitializer.index; - if (braceDepthAt(structuralSource, loadMethod.openingBrace, returnIndex) !== 1) continue; - const openingParenthesis = - bodyStart + returnedInitializer.index + returnedInitializer[0].lastIndexOf("("); - const closingParenthesis = matchingParenthesis(structuralSource, openingParenthesis); - if (closingParenthesis == null || closingParenthesis >= loadMethod.closingBrace) continue; - const structuralArguments = structuralSource.slice(openingParenthesis + 1, closingParenthesis); - const valueArguments = valueSource.slice(openingParenthesis + 1, closingParenthesis); - const expression = topLevelLabeledExpression( - structuralArguments, - valueArguments, - "publishableKey", - ); - if (expression) expressions.push(expression); - returnPattern.lastIndex = closingParenthesis - bodyStart + 1; - } - - return expressions.length === 1 ? expressions[0] : undefined; -} - -function correlatedDecodedDictionaryNames( - structuralSource: string, - valueSource: string, - method: DirectStaticMethodEvidence, - exactURLVariables: Set, -): Set { - const body = valueSource.slice(method.openingBrace + 1, method.closingBrace); - const dataVariables = new Set(); - for (const urlVariable of exactURLVariables) { - const pattern = new RegExp( - `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:try\\s*[?!]?\\s*)?Data\\s*\\(\\s*contentsOf\\s*:\\s*${escapeRegularExpression(urlVariable)}\\b`, - "g", - ); - for (const match of body.matchAll(pattern)) { - if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { - dataVariables.add(match[1]); - } - } - } - - const propertyListVariables = new Set(); - for (const dataVariable of dataVariables) { - const pattern = new RegExp( - `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*(?:try\\s*[?!]?\\s*)?PropertyListSerialization\\s*\\.\\s*propertyList\\s*\\(\\s*from\\s*:\\s*${escapeRegularExpression(dataVariable)}\\b`, - "g", - ); - for (const match of body.matchAll(pattern)) { - if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { - propertyListVariables.add(match[1]); - } - } - } - - const dictionaryVariables = new Set(); - for (const propertyListVariable of propertyListVariables) { - const pattern = new RegExp( - `\\blet\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapeRegularExpression(propertyListVariable)}\\s+as\\s*\\?\\s*\\[\\s*String\\s*:\\s*Any\\s*\\]`, - "g", - ); - for (const match of body.matchAll(pattern)) { - if (match[1] && hasUniqueImmutableInitializedLocal(structuralSource, method, match[1])) { - dictionaryVariables.add(match[1]); - } - } - } - - let changed = true; - while (changed) { - changed = false; - for (const sourceVariable of dictionaryVariables) { - const aliasPattern = new RegExp( - `(?:^|[;{}\\n\\r])\\s*let\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s*:\\s*[^=;{}\\n\\r]+)?\\s*=\\s*${escapeRegularExpression(sourceVariable)}\\b`, - "g", - ); - for (const match of body.matchAll(aliasPattern)) { - const alias = match[1]; - if ( - alias && - !dictionaryVariables.has(alias) && - hasUniqueImmutableInitializedLocal(structuralSource, method, alias) - ) { - dictionaryVariables.add(alias); - changed = true; - } - } - - // Support the direct fixture's definite-initialization form: - // `let values: [String: Any]`, assigned either the decoded immutable - // dictionary or `[:]` in an exact if/else. These are initializations of - // a `let`, not later mutations. - const conditionalAliasPattern = - /\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*\[\s*String\s*:\s*Any\s*\](?!\s*=)/g; - for (const aliasDeclaration of body.matchAll(conditionalAliasPattern)) { - const alias = aliasDeclaration[1]; - if ( - !alias || - alias === sourceVariable || - dictionaryVariables.has(alias) || - !hasUniqueDirectImmutableLocalDeclaration(structuralSource, method, alias) - ) { - continue; - } - const escapedAlias = escapeRegularExpression(alias); - const escapedSource = escapeRegularExpression(sourceVariable); - const assignments = [ - ...body.matchAll(new RegExp(`\\b${escapedAlias}\\s*=\\s*([^;{}\\n\\r]+)`, "g")), - ].map((match) => match[1]?.replace(/\s+/g, "")); - if ( - assignments.length !== 2 || - assignments.filter((assignment) => assignment === sourceVariable).length !== 1 || - assignments.filter((assignment) => assignment === "[:]").length !== 1 || - new RegExp( - `\\b${escapedAlias}\\s*(?:\\[[^\\]\\n\\r]*\\]|\\.[A-Za-z_][A-Za-z0-9_]*)\\s*=(?!=)`, - ).test(body) || - new RegExp( - `\\b${escapedAlias}\\s*\\.\\s*(?:append|appendContentsOf|insert|remove|removeAll|removeValue|replaceSubrange|reserveCapacity|sort|swapAt|updateValue)\\s*\\(`, - ).test(body) - ) { - continue; - } - const exactBranches = new RegExp( - `\\{\\s*${escapedAlias}\\s*=\\s*${escapedSource}\\s*\\}\\s*else\\s*\\{\\s*${escapedAlias}\\s*=\\s*\\[\\s*:\\s*\\]\\s*\\}`, - ).test(body); - if (!exactBranches) continue; - dictionaryVariables.add(alias); - changed = true; - } - } - } - return dictionaryVariables; -} - -function directlyReturnedDictionaryName( - structuralSource: string, - method: DirectStaticMethodEvidence, - dictionaryNames: Set, -): string | undefined { - const bodyStart = method.openingBrace + 1; - const body = structuralSource.slice(bodyStart, method.closingBrace); - const returnedNames: string[] = []; - const returnPattern = /\breturn\s+([A-Za-z_][A-Za-z0-9_]*)\b/g; - let returned: RegExpExecArray | null; - while ((returned = returnPattern.exec(body)) !== null) { - const name = returned[1]; - const returnIndex = bodyStart + returned.index; - if ( - name && - dictionaryNames.has(name) && - braceDepthAt(structuralSource, method.openingBrace, returnIndex) === 1 - ) { - returnedNames.push(name); - } - } - return returnedNames.length === 1 ? returnedNames[0] : undefined; -} - -function directExactKeyLookupBase(valueExpression: string): string | undefined { - const subscript = - /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[\s*"CLERK_PUBLISHABLE_KEY"\s*\]\s*(?:as\s*\?\s*String)?\s*$/.exec( - valueExpression, - ); - if (subscript?.[1]) return subscript[1]; - return /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(?:value|object)\s*\(\s*forKey\s*:\s*"CLERK_PUBLISHABLE_KEY"\s*\)\s*(?:as\s*\?\s*String)?\s*$/.exec( - valueExpression, - )?.[1]; -} - -interface ReturnStatementEvidence { - index: number; - depth: number; - structuralExpression: string; - valueExpression: string; -} - -function returnStatements( - structuralSource: string, - valueSource: string, - method: DirectStaticMethodEvidence, -): ReturnStatementEvidence[] { - const bodyStart = method.openingBrace + 1; - const body = structuralSource.slice(bodyStart, method.closingBrace); - const statements: ReturnStatementEvidence[] = []; - const returnPattern = /\breturn\b/g; - let returned: RegExpExecArray | null; - - while ((returned = returnPattern.exec(body)) !== null) { - const returnIndex = bodyStart + returned.index; - let expressionStart = returnIndex + returned[0].length; - while ( - structuralSource[expressionStart] === " " || - structuralSource[expressionStart] === "\t" - ) { - expressionStart++; - } - - let expressionEnd = expressionStart; - let parenthesisDepth = 0; - let bracketDepth = 0; - let braceDepth = 0; - for (; expressionEnd < method.closingBrace; expressionEnd++) { - const character = structuralSource[expressionEnd]; - if ( - (character === "\n" || character === "\r" || character === ";") && - parenthesisDepth === 0 && - bracketDepth === 0 && - braceDepth === 0 - ) { - break; - } - if (character === "}" && parenthesisDepth === 0 && bracketDepth === 0 && braceDepth === 0) { - break; - } - if (character === "(") parenthesisDepth++; - if (character === ")") parenthesisDepth--; - if (character === "[") bracketDepth++; - if (character === "]") bracketDepth--; - if (character === "{") braceDepth++; - if (character === "}") braceDepth--; - } - - statements.push({ - index: returnIndex, - depth: braceDepthAt(structuralSource, method.openingBrace, returnIndex), - structuralExpression: structuralSource.slice(expressionStart, expressionEnd).trim(), - valueExpression: valueSource.slice(expressionStart, expressionEnd).trim(), - }); - returnPattern.lastIndex = Math.max(returnPattern.lastIndex, expressionEnd - bodyStart); - } - - return statements; -} - -type ExactLookupTransform = "direct" | "normalized"; - -function exactResolverLookupTransform( - expression: string, - plistParameter: string, - keyParameter: string, -): ExactLookupTransform | undefined { - const lookup = `${escapeRegularExpression(plistParameter)}\\s*\\[\\s*${escapeRegularExpression(keyParameter)}\\s*\\]\\s*(?:as\\s*\\?\\s*String)?`; - if (new RegExp(`^${lookup}$`).test(expression)) return "direct"; - if (new RegExp(`^normalized\\s*\\(\\s*${lookup}\\s*\\)$`).test(expression)) { - return "normalized"; - } - return undefined; -} - -function isCanonicalNormalizer( - structuralSource: string, - methods: DirectStaticMethodEvidence[], -): boolean { - const candidates = methods.filter((method) => method.name === "normalized"); - if (candidates.length !== 1) return false; - const normalizer = candidates[0]; - if (!normalizer) return false; - const parameters = topLevelCommaSeparated(normalizer.parameters); - if (parameters.length !== 1) return false; - const parameter = /^_\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*\?$/.exec(parameters[0] ?? ""); - const parameterName = parameter?.[1]; - if (!parameterName || !/^\s*->\s*String\s*\?\s*$/.test(normalizer.header)) return false; - - const body = structuralSource.slice(normalizer.openingBrace + 1, normalizer.closingBrace); - const escapedParameter = escapeRegularExpression(parameterName); - const canonicalBody = new RegExp( - `^\\s*guard\\s+let\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*${escapedParameter}\\s*\\?\\s*\\.\\s*trimmingCharacters\\s*\\(\\s*in\\s*:\\s*\\.\\s*whitespacesAndNewlines\\s*\\)\\s*,\\s*!\\s*\\1\\s*\\.\\s*isEmpty\\s+else\\s*\\{\\s*return\\s+nil\\s*;?\\s*\\}\\s*return\\s+\\1\\s*;?\\s*$`, - ); - return canonicalBody.test(body); -} - -function hasResolverParameterMutation( - structuralSource: string, - method: DirectStaticMethodEvidence, - parameter: string, -): boolean { - return hasParameterMutationOrShadowing(structuralSource, method, parameter); -} - -function isExactEnvironmentOverrideReturn( - structuralSource: string, - method: DirectStaticMethodEvidence, - returned: ReturnStatementEvidence, - processInfoParameter: string, - keyParameter: string, - allowNormalized: boolean, -): boolean { - const returnedName = /^([A-Za-z_][A-Za-z0-9_]*)$/.exec(returned.structuralExpression)?.[1]; - if (!returnedName || returned.depth !== 2) return false; - - const bodyStart = method.openingBrace + 1; - const body = structuralSource.slice(bodyStart, method.closingBrace); - const environmentLookup = `${escapeRegularExpression(processInfoParameter)}\\s*\\.\\s*environment\\s*\\[\\s*${escapeRegularExpression(keyParameter)}\\s*\\]`; - const trustedLookup = allowNormalized - ? `(?:${environmentLookup}|normalized\\s*\\(\\s*${environmentLookup}\\s*\\))` - : environmentLookup; - const pattern = new RegExp( - `\\bif\\s+let\\s+${escapeRegularExpression(returnedName)}\\s*=\\s*${trustedLookup}\\s*\\{`, - "g", - ); - let conditional: RegExpExecArray | null; - - while ((conditional = pattern.exec(body)) !== null) { - const openingBrace = bodyStart + conditional.index + conditional[0].lastIndexOf("{"); - if (braceDepthAt(structuralSource, method.openingBrace, openingBrace) !== 1) continue; - const closingBrace = matchingBrace(structuralSource, openingBrace); - if ( - closingBrace == null || - returned.index <= openingBrace || - returned.index >= closingBrace || - braceDepthAt(structuralSource, openingBrace, returned.index) !== 1 - ) { - continue; - } - const afterConditional = structuralSource.slice(closingBrace + 1, method.closingBrace); - if (/^\s*else\b/.test(afterConditional)) return false; - return true; - } - - return false; -} - -function isExactGuardNilReturn( - structuralSource: string, - method: DirectStaticMethodEvidence, - returned: ReturnStatementEvidence, -): boolean { - if (returned.structuralExpression !== "nil" || returned.depth !== 2) return false; - - const bodyStart = method.openingBrace + 1; - const body = structuralSource.slice(bodyStart, method.closingBrace); - const guardPattern = /\bguard\b[^{};]*\belse\s*\{/g; - let guarded: RegExpExecArray | null; - while ((guarded = guardPattern.exec(body)) !== null) { - if (braceDepthAt(structuralSource, method.openingBrace, bodyStart + guarded.index) !== 1) { - continue; - } - const openingBrace = bodyStart + guarded.index + guarded[0].lastIndexOf("{"); - const closingBrace = matchingBrace(structuralSource, openingBrace); - if ( - closingBrace == null || - returned.index <= openingBrace || - returned.index >= closingBrace || - braceDepthAt(structuralSource, openingBrace, returned.index) !== 1 - ) { - continue; - } - return /^\s*return\s+nil\s*;?\s*$/.test(structuralSource.slice(openingBrace + 1, closingBrace)); - } - - return false; -} - -function hasExactDelegatedResolverReturnFlow( - structuralSource: string, - valueSource: string, - resolver: DirectStaticMethodEvidence, - reachableMethods: DirectStaticMethodEvidence[], - resolverParameters: NamedParameterEvidence[], - plistParameter: NamedParameterEvidence, - keyParameter: NamedParameterEvidence, -): boolean { - if ( - hasResolverParameterMutation(structuralSource, resolver, plistParameter.localName) || - hasResolverParameterMutation(structuralSource, resolver, keyParameter.localName) - ) { - return false; - } - - const statements = returnStatements(structuralSource, valueSource, resolver); - const directReturns = statements.filter((returned) => returned.depth === 1); - const directTransform = directReturns[0] - ? exactResolverLookupTransform( - directReturns[0].structuralExpression, - plistParameter.localName, - keyParameter.localName, - ) - : undefined; - if ( - directReturns.length !== 1 || - !directTransform || - (directTransform === "normalized" && !isCanonicalNormalizer(structuralSource, reachableMethods)) - ) { - return false; - } - - const nestedReturns = statements.filter((returned) => returned.depth !== 1); - if (nestedReturns.length === 0) return true; - const processInfoParameter = resolverParameters.find( - (parameter) => parameter.externalName === "processInfo", - ); - if ( - processInfoParameter && - hasResolverParameterMutation(structuralSource, resolver, processInfoParameter.localName) - ) { - return false; - } - let environmentOverrideCount = 0; - for (const returned of nestedReturns) { - if (isExactGuardNilReturn(structuralSource, resolver, returned)) continue; - if ( - processInfoParameter && - isExactEnvironmentOverrideReturn( - structuralSource, - resolver, - returned, - processInfoParameter.localName, - keyParameter.localName, - directTransform === "normalized", - ) - ) { - environmentOverrideCount++; - continue; - } - return false; - } - return environmentOverrideCount <= 1; -} - -function exactDelegatedResolverFlow( - structuralSource: string, - valueSource: string, - typeSymbol: string, - loadMethod: DirectStaticMethodEvidence, - reachableMethods: DirectStaticMethodEvidence[], - resourceEvidence: ExactMainBundleResourceEvidence, - expression: RedactedExpressionEvidence, -): boolean { - const loadParameters = topLevelCommaSeparated(loadMethod.parameters); - if ( - loadParameters.length !== 2 || - !loadParameters.some((parameter) => - /^bundle\s*:\s*Bundle\s*=\s*(?:Bundle\s*)?\.\s*main$/.test(parameter), - ) || - !loadParameters.some((parameter) => - /^processInfo\s*:\s*ProcessInfo\s*=\s*(?:ProcessInfo\s*)?\.\s*processInfo$/.test(parameter), - ) || - hasParameterMutationOrShadowing(structuralSource, loadMethod, "bundle") || - hasParameterMutationOrShadowing(structuralSource, loadMethod, "processInfo") - ) { - return false; - } - - const escapedType = escapeRegularExpression(typeSymbol); - const resolverCall = new RegExp( - `^\\s*(?:(?:Self|${escapedType})\\s*\\.\\s*)?([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`, - ).exec(expression.structural); - const resolverName = resolverCall?.[1]; - if (!resolverCall || !resolverName) return false; - const openingParenthesis = expression.structural.indexOf("(", resolverCall.index); - const closingParenthesis = matchingParenthesis(expression.structural, openingParenthesis); - if ( - closingParenthesis == null || - expression.structural.slice(closingParenthesis + 1).trim() !== "" - ) { - return false; - } - const valueArguments = expression.value.slice(openingParenthesis + 1, closingParenthesis); - const argumentSegments = topLevelCommaSeparated(valueArguments); - if ( - argumentSegments.length !== 3 || - !argumentSegments.some((argument) => /^for\s*:\s*"CLERK_PUBLISHABLE_KEY"\s*$/.test(argument)) - ) { - return false; - } - const plistArgument = argumentSegments - .map((argument) => /^plistValues\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(argument)?.[1]) - .find((value): value is string => value != null); - const processInfoArgument = argumentSegments - .map((argument) => /^processInfo\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(argument)?.[1]) - .find((value): value is string => value != null); - if (!plistArgument || processInfoArgument !== "processInfo") return false; - - const resolverCalls = directCallArguments(structuralSource, loadMethod, typeSymbol, resolverName); - if (resolverCalls.length !== 1) return false; - - const resolverCandidates = reachableMethods.filter((method) => method.name === resolverName); - if (resolverCandidates.length !== 1) return false; - const resolver = resolverCandidates[0]; - if (!resolver) return false; - const resolverParameters = namedParameters(resolver.parameters); - const keyParameter = resolverParameters.find((parameter) => parameter.externalName === "for"); - const processInfoParameter = resolverParameters.find( - (parameter) => parameter.externalName === "processInfo", - ); - const plistParameter = resolverParameters.find( - (parameter) => parameter.externalName === "plistValues", - ); - const resolverParameterSegments = topLevelCommaSeparated(resolver.parameters); - if ( - resolverParameterSegments.length !== 3 || - !resolverParameterSegments.some((parameter) => - /^for\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*String$/.test(parameter), - ) || - !resolverParameterSegments.some((parameter) => - /^processInfo\s*:\s*ProcessInfo$/.test(parameter), - ) || - !resolverParameterSegments.some((parameter) => - /^plistValues\s*:\s*\[\s*String\s*:\s*Any\s*\]$/.test(parameter), - ) || - !keyParameter || - !processInfoParameter || - !plistParameter - ) { - return false; - } - if ( - !hasExactDelegatedResolverReturnFlow( - structuralSource, - valueSource, - resolver, - reachableMethods, - resolverParameters, - plistParameter, - keyParameter, - ) - ) { - return false; - } - - const loadBodyStart = loadMethod.openingBrace + 1; - const structuralLoadBody = structuralSource.slice(loadBodyStart, loadMethod.closingBrace); - const assignmentPattern = new RegExp( - `\\blet\\s+${escapeRegularExpression(plistArgument)}\\s*=\\s*(?:(?:Self|${escapedType})\\s*\\.\\s*)?([A-Za-z_][A-Za-z0-9_]*)\\s*\\(`, - "g", - ); - const resourceAssignments: Array<{ - method: DirectStaticMethodEvidence; - arguments: string; - }> = []; - let assignment: RegExpExecArray | null; - while ((assignment = assignmentPattern.exec(structuralLoadBody)) !== null) { - const assignmentIndex = loadBodyStart + assignment.index; - if (braceDepthAt(structuralSource, loadMethod.openingBrace, assignmentIndex) !== 1) continue; - const resourceName = assignment[1]; - if (!resourceName) continue; - const localOpeningParenthesis = assignment.index + assignment[0].lastIndexOf("("); - const resourceOpeningParenthesis = loadBodyStart + localOpeningParenthesis; - const resourceClosingParenthesis = matchingParenthesis( - structuralSource, - resourceOpeningParenthesis, - ); - if ( - resourceClosingParenthesis == null || - resourceClosingParenthesis >= loadMethod.closingBrace - ) { - continue; - } - const candidates = reachableMethods.filter((method) => method.name === resourceName); - if (candidates.length === 1 && candidates[0]) { - resourceAssignments.push({ - method: candidates[0], - arguments: structuralSource.slice( - resourceOpeningParenthesis + 1, - resourceClosingParenthesis, - ), - }); - } - } - if ( - resourceAssignments.length !== 1 || - !hasUniqueImmutableInitializedLocal(structuralSource, loadMethod, plistArgument) - ) { - return false; - } - const resourceAssignment = resourceAssignments[0]; - if (!resourceAssignment || !/^\s*bundle\s*:\s*bundle\s*$/.test(resourceAssignment.arguments)) { - return false; - } - const resourceMethod = resourceAssignment.method; - const resourceParameters = topLevelCommaSeparated(resourceMethod.parameters); - if ( - resourceParameters.length !== 1 || - !/^bundle\s*:\s*Bundle$/.test(resourceParameters[0] ?? "") || - !/^\s*->\s*\[\s*String\s*:\s*Any\s*\]\s*$/.test(resourceMethod.header) || - hasParameterMutationOrShadowing(structuralSource, resourceMethod, "bundle") - ) { - return false; - } - const resourceCalls = directCallArguments( - structuralSource, - loadMethod, - typeSymbol, - resourceMethod.name, - ); - if (resourceCalls.length !== 1) return false; - - const decodedDictionaries = correlatedDecodedDictionaryNames( - structuralSource, - valueSource, - resourceMethod, - resourceEvidence.urlVariablesByMethod.get(resourceMethod.openingBrace) ?? new Set(), - ); - return ( - directlyReturnedDictionaryName(structuralSource, resourceMethod, decodedDictionaries) != null - ); -} - -function loadReturnsExactPublishableKey( - structuralSource: string, - valueSource: string, - typeSymbol: string, - loadMethod: DirectStaticMethodEvidence, - reachableMethods: DirectStaticMethodEvidence[], - resourceEvidence: ExactMainBundleResourceEvidence, -): boolean { - const expression = returnedPublishableKeyExpression( - structuralSource, - valueSource, - typeSymbol, - loadMethod, - ); - if (!expression) return false; - const directLookupBase = directExactKeyLookupBase(expression.value); - if (directLookupBase) { - const decodedDictionaries = correlatedDecodedDictionaryNames( - structuralSource, - valueSource, - loadMethod, - resourceEvidence.urlVariablesByMethod.get(loadMethod.openingBrace) ?? new Set(), - ); - return decodedDictionaries.has(directLookupBase); - } - return exactDelegatedResolverFlow( - structuralSource, - valueSource, - typeSymbol, - loadMethod, - reachableMethods, - resourceEvidence, - expression, - ); -} - -function provenLocalSecretsRuntimeSymbols(structuralSource: string, valueSource: string): string[] { - const symbols = new Set(); - const declarationPattern = - /\b(?:struct|class|enum|actor)\s+((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\b/g; - let declaration: RegExpExecArray | null; - - while ((declaration = declarationPattern.exec(structuralSource)) !== null) { - const symbol = declaration[1]; - if ( - !symbol || - braceDepthAt(structuralSource, 0, declaration.index) !== 0 || - isInsideConditionalCompilation(structuralSource, declaration.index) - ) { - continue; - } - const openingBrace = structuralSource.indexOf("{", declaration.index + declaration[0].length); - if (openingBrace === -1) continue; - const headerRemainder = structuralSource.slice( - declaration.index + declaration[0].length, - openingBrace, - ); - if (/[;}]/.test(headerRemainder) || /\b(?:struct|class|enum|actor)\b/.test(headerRemainder)) { - continue; - } - const closingBrace = matchingBrace(structuralSource, openingBrace); - if (closingBrace == null) continue; - - const structuralBody = structuralSource.slice(openingBrace + 1, closingBrace); - // Any conditional member makes it ambiguous whether the loader and its - // resource/key path ship in the selected configuration. - if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(structuralBody)) continue; - - const methods = directStaticMethods(structuralSource, openingBrace, closingBrace); - const loadEvidence = directZeroArgumentLoadEvidence(methods); - if (!loadEvidence) continue; - const reachableMethods = reachableStaticMethods( - structuralSource, - symbol, - methods, - loadEvidence, - ); - if (!reachableMethods) continue; - - const resourceEvidence = exactMainBundleResourceEvidence( - structuralSource, - valueSource, - symbol, - reachableMethods, - loadEvidence, - ); - if ( - resourceEvidence.urlVariablesByMethod.size > 0 && - loadReturnsExactPublishableKey( - structuralSource, - valueSource, - symbol, - loadEvidence, - reachableMethods, - resourceEvidence, - ) - ) { - symbols.add(symbol); - } - } - - return [...symbols].sort(); -} - function matchingBrace(source: string, openingBrace: number): number | undefined { let depth = 0; for (let index = openingBrace; index < source.length; index++) { @@ -1551,11 +405,9 @@ function publishableKeyWiring( ): { wiring: IOSPublishableKeyWiring; inlinePublishableKey?: IOSInlinePublishableKeyInspection; - localSecretsSymbol?: string; - localSecretsUsesCanonicalLoad?: boolean; } { const label = /\bpublishableKey\s*:/.exec(sanitizedCallBody); - if (!label) return { wiring: "unknown" }; + if (!label) return { wiring: "custom" }; const expressionStart = label.index + label[0].length; let expressionEnd = sanitizedCallBody.length; let parenthesisDepth = 0; @@ -1600,48 +452,15 @@ function publishableKeyWiring( } } - const localSecrets = - /\b((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\b\s*\.\s*(load\s*\(|(?:key|publishableKey)\b)/.exec( - expression, - ); - if (localSecrets?.[1]) { - const canonicalLoad = - /^\s*((?:[A-Za-z_][A-Za-z0-9_]*)?LocalSecrets)\s*\.\s*load\s*\(\s*\)\s*\.\s*publishableKey\b([\s\S]*)$/.exec( - expression, - ); - const remainder = canonicalLoad?.[2] ?? ""; - const originalRemainder = originalExpression.slice(expression.length - remainder.length); - const canonicalRemainder = - remainder.trim() === "" || /^\s*\?\?\s*""\s*$/.test(originalRemainder); - return { - wiring: "local-secrets-loader", - localSecretsSymbol: localSecrets[1], - localSecretsUsesCanonicalLoad: canonicalLoad?.[1] === localSecrets[1] && canonicalRemainder, - }; - } - if ( - has(expression, /\bProcessInfo\s*\.\s*processInfo\s*\.\s*environment\b/) && - has( - originalExpression, - /\bProcessInfo\s*\.\s*processInfo\s*\.\s*environment\s*\[\s*"CLERK_PUBLISHABLE_KEY"\s*\]/, - ) - ) { - return { wiring: "process-info-environment" }; - } - return { wiring: "unknown" }; -} - -interface PendingConfigureCall extends IOSConfigureCallEvidence { - localSecretsSymbol?: string; - localSecretsUsesCanonicalLoad?: boolean; + return { wiring: "custom" }; } function configureCallEvidence( sanitizedSource: string, originalSource: string, evidence: IOSSourceEvidence, -): PendingConfigureCall[] { - const calls: PendingConfigureCall[] = []; +): IOSConfigureCallEvidence[] { + const calls: IOSConfigureCallEvidence[] = []; const initializerBodies = mainInitializerBodies(sanitizedSource); const pattern = /\bClerk\s*\.\s*configure\s*\(/g; let match: RegExpExecArray | null; @@ -1652,7 +471,7 @@ function configureCallEvidence( if (closingParenthesis == null) { calls.push({ ...evidence, - publishableKeyWiring: "unknown", + publishableKeyWiring: "custom", startupBinding: isDirectStatementInMainInitializer( sanitizedSource, match.index, @@ -1678,8 +497,6 @@ function configureCallEvidence( ) ? "app-init" : "unproven", - localSecretsSymbol: classification.localSecretsSymbol, - localSecretsUsesCanonicalLoad: classification.localSecretsUsesCanonicalLoad, }); pattern.lastIndex = closingParenthesis + 1; } @@ -1741,9 +558,7 @@ export async function inspectSwiftSources( const entryPoints: IOSSourceEvidence[] = []; const importsClerkKit: IOSSourceEvidence[] = []; const importsClerkKitUI: IOSSourceEvidence[] = []; - const pendingConfigureCalls: PendingConfigureCall[] = []; - const localSecretsRuntimeBindings: IOSSourceEvidence[] = []; - const localSecretsRuntimeSymbols = new Set(); + const configureCalls: IOSConfigureCallEvidence[] = []; const environmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; @@ -1768,10 +583,8 @@ export async function inspectSwiftSources( sourceFilesScanned++; const structuralSource = sanitizeSwiftSourceWithStatus(source); - const valueSource = sourceWithoutComments(source); - if (!structuralSource.complete || !valueSource.complete) evidenceComplete = false; + if (!structuralSource.complete) evidenceComplete = false; const sanitized = withoutPreviewOnlyRegions(structuralSource.sanitizedSource); - const uncommented = withoutPreviewOnlyRegions(valueSource.sanitizedSource); const evidence = { path: file.relativePath }; const importsKit = has( sanitized, @@ -1786,13 +599,8 @@ export async function inspectSwiftSources( if (has(sanitized, /@main\b/)) entryPoints.push(evidence); if (importsKit) importsClerkKit.push(evidence); if (importsUI) importsClerkKitUI.push(evidence); - const runtimeSymbols = provenLocalSecretsRuntimeSymbols(sanitized, uncommented); - for (const symbol of runtimeSymbols) { - localSecretsRuntimeSymbols.add(symbol); - localSecretsRuntimeBindings.push(evidence); - } if (importsClerkModule) { - pendingConfigureCalls.push(...configureCallEvidence(sanitized, source, evidence)); + configureCalls.push(...configureCallEvidence(sanitized, source, evidence)); } if ( importsClerkModule && @@ -1817,8 +625,7 @@ export async function inspectSwiftSources( const anyClerkEvidence = importsClerkKit.length + importsClerkKitUI.length + - pendingConfigureCalls.length + - localSecretsRuntimeBindings.length + + configureCalls.length + environmentInjections.length + environmentConsumers.length + authFlowReferences.length > @@ -1826,26 +633,12 @@ export async function inspectSwiftSources( const status = entryPoints.length > 1 ? "ambiguous" - : pendingConfigureCalls.length > 0 && environmentInjections.length > 0 + : configureCalls.length > 0 && environmentInjections.length > 0 ? "complete" : anyClerkEvidence ? "partial" : "absent"; - const configureCalls: IOSConfigureCallEvidence[] = pendingConfigureCalls.map( - ({ localSecretsSymbol, localSecretsUsesCanonicalLoad, ...call }) => ({ - ...call, - ...(call.publishableKeyWiring === "local-secrets-loader" && { - localSecretsRuntimeBinding: - localSecretsUsesCanonicalLoad && - localSecretsSymbol && - localSecretsRuntimeSymbols.has(localSecretsSymbol) - ? ("proven" as const) - : ("unproven" as const), - }), - }), - ); - return { sourceFilesScanned, evidenceComplete, @@ -1853,7 +646,6 @@ export async function inspectSwiftSources( importsClerkKit, importsClerkKitUI, configureCalls, - localSecretsRuntimeBindings, environmentInjections, environmentConsumers, authFlowReferences, diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 31a894473..dc019a818 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -24,13 +24,10 @@ export interface IOSDiagnostic { | "xcode.external-path" | "xcode.generated-project" | "xcode.incomplete-source-membership" - | "xcode.incomplete-scheme-discovery" - | "xcode.incomplete-local-secrets-discovery" | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key" - | "clerk.conflicting-publishable-keys" - | "clerk.unconsumed-publishable-key-source"; + | "clerk.conflicting-publishable-keys"; severity: IOSDiagnosticSeverity; message: string; remedy?: string; @@ -95,11 +92,7 @@ export interface IOSClerkPackageState { clerkKitUI: IOSProductLinkState; } -export type IOSPublishableKeyWiring = - | "inline-literal" - | "local-secrets-loader" - | "process-info-environment" - | "unknown"; +export type IOSPublishableKeyWiring = "inline-literal" | "custom"; export type IOSInlinePublishableKeyInspection = | { @@ -116,8 +109,6 @@ export interface IOSConfigureCallEvidence extends IOSSourceEvidence { inlinePublishableKey?: IOSInlinePublishableKeyInspection; /** Whether this call is a direct statement in the selected @main type's init(). */ startupBinding: "app-init" | "unproven"; - /** Whether the referenced LocalSecrets symbol is the exact inspected runtime loader. */ - localSecretsRuntimeBinding?: "proven" | "unproven"; } export interface IOSSwiftInspection { @@ -128,8 +119,6 @@ export interface IOSSwiftInspection { importsClerkKit: IOSSourceEvidence[]; importsClerkKitUI: IOSSourceEvidence[]; configureCalls: IOSConfigureCallEvidence[]; - /** Exact target source that loads LocalSecrets.plist and CLERK_PUBLISHABLE_KEY. */ - localSecretsRuntimeBindings: IOSSourceEvidence[]; environmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; authFlowReferences: IOSSourceEvidence[]; @@ -137,12 +126,6 @@ export interface IOSSwiftInspection { status: "complete" | "partial" | "absent" | "ambiguous"; } -export interface IOSRuntimeKeySink { - kind: "local-secrets-plist"; - /** Project-root-relative path. The publishable-key value is never exposed. */ - path: string; -} - export interface IOSAppTarget { id: string; name: string; @@ -151,8 +134,6 @@ export interface IOSAppTarget { configurations: IOSBuildConfiguration[]; packages: IOSClerkPackageState; swift: IOSSwiftInspection; - /** Target-owned runtime destinations proven from the Xcode project graph. */ - runtimeKeySinks: IOSRuntimeKeySink[]; } export interface IOSProjectInspection { From 61a586f0535a99558638504f9bfd5f2434c2f3ee Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 11:18:16 -0400 Subject: [PATCH 30/45] refactor(ios): simplify publishable key state --- .../src/commands/init/ios/inspect.test.ts | 53 ++++++++++++------- .../cli-core/src/commands/init/ios/inspect.ts | 42 +++------------ .../src/commands/init/ios/products.test.ts | 8 +-- .../cli-core/src/commands/init/ios/types.ts | 24 ++++----- 4 files changed, 52 insertions(+), 75 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 3e0b3fc02..16a3eb412 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -423,11 +423,7 @@ describe("inspectIOSProject", () => { sourceFilesScanned: 1, }); expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], + state: "unproven", }); const fixtureKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; expect(JSON.stringify(inspection)).not.toContain(fixtureKey); @@ -1038,14 +1034,10 @@ struct MyApp: App { const inspection = await inspectIOSProject(root, { target: "MyApp" }); expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: true, - conflict: false, + state: "valid", source: "MyApp/MyAppApp.swift", frontendApiHost: "inline.clerk.example", instanceType: "development", - candidateSources: ["MyApp/MyAppApp.swift"], - invalidSources: [], }); expect(JSON.stringify(inspection)).not.toContain(publishableKey); }); @@ -1072,16 +1064,41 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, + state: "invalid", source: "MyApp/MyAppApp.swift", - conflict: false, - candidateSources: ["MyApp/MyAppApp.swift"], - invalidSources: ["MyApp/MyAppApp.swift"], }); expect(JSON.stringify(inspection)).not.toContain(invalidInlineKey); }); + test("marks multiple valid configure calls as unproven instead of invalid", async () => { + const root = await fixture({ includeKey: false }); + const startupKey = `pk_test_${Buffer.from("startup.clerk.example$").toString("base64")}`; + const deferredKey = `pk_test_${Buffer.from("deferred.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${startupKey}") } + + var body: some Scene { WindowGroup { Text("Hello") } } + + func configureAgain() { + Clerk.configure(publishableKey: "${deferredKey}") + } +} +`, + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.localPublishableKey).toEqual({ state: "unproven" }); + expect(JSON.stringify(inspection)).not.toContain(startupKey); + expect(JSON.stringify(inspection)).not.toContain(deferredKey); + }); + test("does not treat web-framework key names as native iOS configuration", async () => { const root = await fixture({ includeKey: false }); const webKey = `pk_test_${Buffer.from("web.clerk.example$").toString("base64")}`; @@ -1090,11 +1107,7 @@ struct MyApp: App { const inspection = await inspectIOSProject(root); expect(inspection.localPublishableKey).toEqual({ - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], + state: "missing", }); }); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 81b49c8b6..52e47cf8e 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -137,18 +137,13 @@ function inspectInlinePublishableKey( diagnostics: IOSDiagnostic[], ): IOSProjectInspectionResult["localPublishableKey"] { if (!target?.swift.evidenceComplete) { - return { - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }; + return { state: "unproven" }; } const calls = target.swift.configureCalls; + if (calls.length === 0) return { state: "missing" }; + const inlineCalls = calls.filter((call) => call.publishableKeyWiring === "inline-literal"); - const candidateSources = [...new Set(inlineCalls.map((call) => call.path))].sort(); // Only the documented, single startup literal proves which Clerk instance // the selected target runs against. Every other expression is custom and is @@ -158,13 +153,7 @@ function inspectInlinePublishableKey( inlineCalls.length !== 1 || inlineCalls[0]?.startupBinding !== "app-init" ) { - return { - evidenceComplete: true, - found: false, - conflict: false, - candidateSources, - invalidSources: [], - }; + return { state: "unproven" }; } const call = inlineCalls[0]; @@ -179,25 +168,14 @@ function inspectInlinePublishableKey( evidence: [{ path: source, keyPath: "Clerk.configure(publishableKey:)" }], }); } - return { - evidenceComplete: true, - found: false, - ...(source ? { source } : {}), - conflict: false, - candidateSources, - invalidSources: source ? [source] : [], - }; + return source ? { state: "invalid", source } : { state: "unproven" }; } return { - evidenceComplete: true, - found: true, + state: "valid", source: call.path, frontendApiHost: call.inlinePublishableKey.frontendApiHost, instanceType: call.inlinePublishableKey.instanceType, - conflict: false, - candidateSources, - invalidSources: [], }; } @@ -1198,13 +1176,7 @@ export async function inspectIOSProject( projects: [], appTargets: [], selection: { state: "none" }, - localPublishableKey: { - evidenceComplete: false, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }, + localPublishableKey: { state: "unproven" }, generatedProject: null, diagnostics: [ { diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index 25618d001..4f89e80de 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -44,13 +44,7 @@ function inspection(selected: IOSAppTarget): IOSProjectInspectionResult { targetName: selected.name, projectPath: selected.projectPath, }, - localPublishableKey: { - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }, + localPublishableKey: { state: "missing" }, generatedProject: null, diagnostics: [], }; diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index dc019a818..ddf0070eb 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -26,8 +26,7 @@ export interface IOSDiagnostic { | "xcode.incomplete-source-membership" | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" - | "clerk.invalid-publishable-key" - | "clerk.conflicting-publishable-keys"; + | "clerk.invalid-publishable-key"; severity: IOSDiagnosticSeverity; message: string; remedy?: string; @@ -159,17 +158,16 @@ export type IOSTargetSelection = | { state: "not-found"; requested: string; candidates: string[] } | { state: "none" }; -export interface IOSLocalPublishableKeyInspection { - /** False when an effective runtime-key source may have been hidden by bounded discovery. */ - evidenceComplete: boolean; - found: boolean; - source?: string; - frontendApiHost?: string; - instanceType?: "development" | "production"; - conflict: boolean; - candidateSources: string[]; - invalidSources: string[]; -} +export type IOSLocalPublishableKeyInspection = + | { + state: "valid"; + source: string; + frontendApiHost: string; + instanceType: "development" | "production"; + } + | { state: "invalid"; source: string } + | { state: "unproven" } + | { state: "missing" }; export interface IOSProjectInspectionResult { schemaVersion: 1; From de5f310ebda710e13a1a63606cef96a305eaa5d6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 16:21:19 -0400 Subject: [PATCH 31/45] fix(init): harden Xcode path resolution --- .../src/commands/init/ios/build-settings.ts | 5 +- .../src/commands/init/ios/discovery.ts | 73 ++++++++++++---- .../src/commands/init/ios/inspect.test.ts | 86 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 3 +- .../src/commands/init/ios/pbx.test.ts | 48 +++++++++++ .../cli-core/src/commands/init/ios/pbx.ts | 14 ++- 6 files changed, 206 insertions(+), 23 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 351bd7ce5..327272bb4 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -9,6 +9,7 @@ import { resolvePbxFilePath, type PbxObject, type PbxObjects, + type PbxParentIndex, } from "./pbx.ts"; import type { IOSBuildConfiguration, @@ -611,7 +612,7 @@ async function settingsForConfiguration( configurationName: string, context: BuildContext, objects: PbxObjects, - parents: Map, + parents: PbxParentIndex, diagnostics: IOSDiagnostic[], inherited: BuildSettingsEvaluation = { settings: {}, @@ -791,7 +792,7 @@ export async function inspectTargetBuildConfigurations(options: { targetId: string; targetObject: PbxObject; objects: PbxObjects; - parents: Map; + parents: PbxParentIndex; diagnostics: IOSDiagnostic[]; }): Promise { const { diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index b6e878e6b..57abcc96e 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -3,7 +3,6 @@ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path import { parse as parsePbxProject } from "@bacons/xcode/json"; import { asString, - asStringArray, buildPbxParentIndex, isRecord, resolvePbxFilePath, @@ -267,12 +266,6 @@ async function referencedProjectsForProject( asString(projectObject.projectDirPath) ?? "", ); const parents = buildPbxParentIndex(objects); - const parentCounts = new Map(); - for (const object of Object.values(objects)) { - for (const childId of asStringArray(object.children)) { - parentCounts.set(childId, (parentCounts.get(childId) ?? 0) + 1); - } - } const projectPaths = new Set(); let complete = true; for (const projectReference of projectReferences) { @@ -286,10 +279,12 @@ async function referencedProjectsForProject( complete = false; continue; } - const parentCount = parentCounts.get(fileReferenceId) ?? 0; const sourceTree = asString(fileReference.sourceTree) ?? ""; const rawPath = asString(fileReference.path); - if (parentCount > 1 || (sourceTree === "" && (!rawPath || !isAbsolute(rawPath)))) { + if ( + parents.get(fileReferenceId) === null || + (sourceTree === "" && (!rawPath || !isAbsolute(rawPath))) + ) { complete = false; continue; } @@ -420,13 +415,59 @@ export async function discoverIOSContainers( }; } -function decodeXMLAttribute(value: string): string { - return value - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("&", "&"); +function validXMLCodePoint(value: number): boolean { + return ( + value === 0x09 || + value === 0x0a || + value === 0x0d || + (value >= 0x20 && value <= 0xd7ff) || + (value >= 0xe000 && value <= 0xfffd) || + (value >= 0x10000 && value <= 0x10ffff) + ); +} + +const XML_NAMED_ENTITIES = [ + [""", '"'], + ["'", "'"], + ["<", "<"], + [">", ">"], + ["&", "&"], +] as const; + +function decodeXMLAttribute(value: string): string | undefined { + const chunks: string[] = []; + let cursor = 0; + while (cursor < value.length) { + const start = value.indexOf("&", cursor); + if (start === -1) { + chunks.push(value.slice(cursor)); + break; + } + chunks.push(value.slice(cursor, start)); + if (value[start + 1] === "#") { + const end = value.indexOf(";", start + 2); + if (end === -1) return undefined; + const reference = value.slice(start + 2, end); + const hexadecimal = reference.startsWith("x"); + const digits = hexadecimal ? reference.slice(1) : reference; + if (!(hexadecimal ? /^[0-9A-Fa-f]+$/ : /^[0-9]+$/).test(digits)) return undefined; + const codePoint = Number.parseInt(digits, hexadecimal ? 16 : 10); + if (!Number.isSafeInteger(codePoint) || !validXMLCodePoint(codePoint)) return undefined; + chunks.push(String.fromCodePoint(codePoint)); + cursor = end + 1; + continue; + } + + const named = XML_NAMED_ENTITIES.find(([entity]) => value.startsWith(entity, start)); + if (named) { + chunks.push(named[1]); + cursor = start + named[0].length; + } else { + chunks.push("&"); + cursor = start + 1; + } + } + return chunks.join(""); } export function xmlAttribute(source: string, name: string): string | undefined { diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 16a3eb412..c8210e917 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -339,6 +339,44 @@ describe("discoverIOSContainers", () => { }); describe("inspectIOSProject", () => { + test("marks source membership incomplete when a group-relative file has multiple parents", async () => { + const root = await fixture({ complete: true }); + const alternateGroupId = "565656565656565656565656"; + await mkdir(join(root, "Alternate")); + await Bun.write( + join(root, "Alternate", "MyAppApp.swift"), + 'import SwiftUI\n\n@main\nstruct AlternateApp: App {\n var body: some Scene { WindowGroup { Text("Alternate") } }\n}\n', + ); + await transformProject(root, (objects) => { + const appGroup = objects[IOS_FIXTURE_IDS.appGroup]!; + delete objects[IOS_FIXTURE_IDS.appGroup]; + objects[alternateGroupId] = { + isa: "PBXGroup", + children: [IOS_FIXTURE_IDS.appFile], + path: "Alternate", + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.appGroup] = appGroup; + objects[IOS_FIXTURE_IDS.mainGroup]!.children = [ + alternateGroupId, + ...((objects[IOS_FIXTURE_IDS.mainGroup]!.children as string[]) ?? []), + ]; + }); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const appMembership = memberships.find( + (membership) => membership.targetId === IOS_FIXTURE_IDS.appTarget, + ); + + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(false); + expect(inspection.appTargets[0]?.swift.sourceFilesScanned).toBe(0); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + expect(appMembership).toMatchObject({ complete: false, files: [] }); + }); + test("reports interrupted file transactions without recovering or changing project bytes", async () => { const root = await fixture({ complete: true }); const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); @@ -865,6 +903,54 @@ let package = Package( expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); }); + test.each([ + ["decimal", "group:MyApp.xcodeproj"], + ["hexadecimal", "group:MyApp.xcodeproj"], + ])("decodes %s numeric entities in workspace locations", async (_label, location) => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + const result = await inspectWorkspace(root, workspace); + + expect(result.inspection.projectPaths).toEqual(["MyApp.xcodeproj"]); + expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); + }); + + test.each(["�", "�", "�", "�"])( + "rejects invalid XML code point %s in a workspace location", + async (reference) => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + const result = await inspectWorkspace(root, workspace); + + expect(result.inspection.projectPaths).toEqual([]); + expect(result.localProjectPaths).toEqual([]); + }, + ); + + test("does not decode numeric syntax introduced by an escaped ampersand", async () => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + + const result = await inspectWorkspace(root, workspace); + + expect(result.inspection.projectPaths).toEqual([]); + expect(result.localProjectPaths).toEqual([]); + }); + test("does not parse a workspace location token from inside another quoted attribute", async () => { const root = await fixture({ workspace: true }); const workspace = join(root, "MyApp.xcworkspace"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 52e47cf8e..9c5ba5c0e 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -28,6 +28,7 @@ import { sanitizeRepositoryURL, type PbxObject, type PbxObjects, + type PbxParentIndex, } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; import { inspectSwiftSources } from "./swift.ts"; @@ -631,7 +632,7 @@ async function sourceFilesForTarget(options: { targetId: string; targetObject: PbxObject; objects: PbxObjects; - parents: Map; + parents: PbxParentIndex; diagnostics: IOSDiagnostic[]; }): Promise<{ files: Array<{ absolutePath: string; relativePath: string }>; diff --git a/packages/cli-core/src/commands/init/ios/pbx.test.ts b/packages/cli-core/src/commands/init/ios/pbx.test.ts index 027238263..8f75671b0 100644 --- a/packages/cli-core/src/commands/init/ios/pbx.test.ts +++ b/packages/cli-core/src/commands/init/ios/pbx.test.ts @@ -92,6 +92,54 @@ describe("resolvePbxFilePath", () => { ), ).toBe("/tmp/Example/Root.swift"); }); + + test("does not resolve a group-relative object with multiple distinct parents", () => { + const file = { + isa: "PBXFileReference", + path: "App.swift", + sourceTree: "", + }; + const firstGroup = { + isa: "PBXGroup", + children: ["file"], + path: "First", + sourceTree: "", + }; + const secondGroup = { + isa: "PBXGroup", + children: ["file"], + path: "Second", + sourceTree: "", + }; + + for (const objects of [ + { firstGroup, secondGroup, file }, + { secondGroup, firstGroup, file }, + ] satisfies PbxObjects[]) { + const parents = buildPbxParentIndex(objects); + + expect(parents.get("file")).toBeNull(); + expect(resolvePbxFilePath("file", objects, parents, "/tmp/Example")).toBeUndefined(); + } + }); + + test("keeps repeated membership in one parent unambiguous", () => { + const objects: PbxObjects = { + group: { + isa: "PBXGroup", + children: ["file", "file"], + path: "Sources", + sourceTree: "", + }, + file: { isa: "PBXFileReference", path: "App.swift", sourceTree: "" }, + }; + const parents = buildPbxParentIndex(objects); + + expect(parents.get("file")).toBe("group"); + expect(resolvePbxFilePath("file", objects, parents, "/tmp/Example")).toBe( + "/tmp/Example/Sources/App.swift", + ); + }); }); describe("sanitizeRepositoryURL", () => { diff --git a/packages/cli-core/src/commands/init/ios/pbx.ts b/packages/cli-core/src/commands/init/ios/pbx.ts index a9b73c753..37b95976a 100644 --- a/packages/cli-core/src/commands/init/ios/pbx.ts +++ b/packages/cli-core/src/commands/init/ios/pbx.ts @@ -2,6 +2,7 @@ import { isAbsolute, resolve } from "node:path"; export type PbxObject = Record & { isa?: string }; export type PbxObjects = Record; +export type PbxParentIndex = Map; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -31,11 +32,15 @@ export function asStringRecord(value: unknown): Record { return result; } -export function buildPbxParentIndex(objects: PbxObjects): Map { - const parents = new Map(); +export function buildPbxParentIndex(objects: PbxObjects): PbxParentIndex { + const parents: PbxParentIndex = new Map(); for (const [id, object] of Object.entries(objects)) { for (const child of asStringArray(object.children)) { - if (!parents.has(child)) parents.set(child, id); + if (!parents.has(child)) { + parents.set(child, id); + } else if (parents.get(child) !== id) { + parents.set(child, null); + } } } return parents; @@ -45,7 +50,7 @@ export function buildPbxParentIndex(objects: PbxObjects): Map { export function resolvePbxFilePath( objectId: string, objects: PbxObjects, - parents: Map, + parents: PbxParentIndex, projectDirectory: string, groupRootDirectory: string = projectDirectory, seen: Set = new Set(), @@ -76,6 +81,7 @@ export function resolvePbxFilePath( if (sourceTree !== "") return undefined; const parentId = parents.get(objectId); + if (parentId === null) return undefined; if (!parentId) return resolve(groupRootDirectory, rawPath); const parentPath = resolvePbxFilePath( parentId, From 3fd4518c6c10fa012d5d6d76a769c5bdf89cd7e1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 17:05:23 -0400 Subject: [PATCH 32/45] fix(init): reject malformed Xcode platform filters --- .../src/commands/init/ios/inspect.test.ts | 31 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 12 ++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index c8210e917..c30b7983d 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1313,6 +1313,37 @@ struct MyApp: App { expect(target?.packages.clerkKit).toBe("declared"); }); + test.each([ + ["a scalar", "futureos"], + ["a partially malformed array", "( ios, 1, )"], + ])( + "does not use %s platformFilters value as authoritative iOS evidence", + async (_description, platformFilters) => { + const root = await fixture({ complete: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + await Bun.write( + projectFile, + original + .replace( + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef`, + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; platformFilters = ${platformFilters}; fileRef`, + ) + .replace( + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; productRef`, + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; platformFilters = ${platformFilters}; productRef`, + ), + ); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + + expect(target?.swift.sourceFilesScanned).toBe(0); + expect(target?.swift.evidenceComplete).toBe(false); + expect(target?.packages.clerkKit).toBe("declared"); + }, + ); + test("reports malformed projects as blocked evidence", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 9c5ba5c0e..d198ac88d 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -118,11 +118,15 @@ function buildFileIOSApplicability(object: PbxObject): { applies: boolean; recognized: boolean; } { + const rawFilters = object.platformFilters; + if ( + rawFilters != null && + (!Array.isArray(rawFilters) || rawFilters.some((item) => typeof item !== "string")) + ) { + return { applies: false, recognized: false }; + } const platformFilter = asString(object.platformFilter); - const filters = [ - ...asStringArray(object.platformFilters), - ...(platformFilter ? [platformFilter] : []), - ]; + const filters = [...asStringArray(rawFilters), ...(platformFilter ? [platformFilter] : [])]; if (filters.length === 0) return { applies: true, recognized: true }; if (filters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter))) { return { applies: true, recognized: true }; From 22d83d7c6e323cdc5ac222a640ec9941360bb14b Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 17:05:27 -0400 Subject: [PATCH 33/45] fix(init): fail closed on interpolated Clerk calls --- .../src/commands/init/ios/swift.test.ts | 77 +++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 62 +++++++++++---- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index f91c31536..0a3a2c5fa 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -75,9 +75,86 @@ describe("sanitizeSwiftSource", () => { expect(interpolated.complete).toBe(false); expect(interpolated.sanitizedSource).not.toContain("Clerk.shared"); }); + + test("keeps ordinary string interpolation complete without hiding later Clerk calls", () => { + const source = String.raw`let message = "Hello \(user.name): \(format("%@", value))" +Clerk.configure(publishableKey: key)`; + + const result = sanitizeSwiftSourceWithStatus(source); + + expect(result.complete).toBe(true); + expect(result.sanitizedSource).not.toContain("user.name"); + expect(result.sanitizedSource).not.toContain("format"); + expect(result.sanitizedSource).toContain("Clerk.configure(publishableKey: key)"); + }); + + test("marks interpolation that contains executable Clerk evidence incomplete", () => { + const configure = sanitizeSwiftSourceWithStatus( + String.raw`let message = "configured: \(Clerk.configure(publishableKey: "pk_test_hidden"))"`, + ); + const auth = sanitizeSwiftSourceWithStatus( + String.raw`let message = #"signed in: \#(try await Clerk.shared.auth.signInWithApple())"#`, + ); + + expect(configure.complete).toBe(false); + expect(configure.sanitizedSource).not.toContain("Clerk.configure"); + expect(auth.complete).toBe(false); + expect(auth.sanitizedSource).not.toContain("Clerk.shared"); + }); }); describe("inspectSwiftSources", () => { + test("fails Clerk evidence closed when a configure call is hidden in interpolation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + String.raw`import ClerkKit + @main struct AppMain: App { + let diagnostic = "\(Clerk.configure(publishableKey: "pk_test_hidden"))" + var body: some Scene { WindowGroup { ContentView() } } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.entryPoints).toEqual([{ path: "App.swift" }]); + expect(inspection.configureCalls).toEqual([]); + }); + + test("keeps ordinary interpolation complete while detecting a real configure call", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + String.raw`import ClerkKit + @main struct AppMain: App { + init() { + let diagnostic = "Hello \(user.name): \(format("%@", value))" + Clerk.configure(publishableKey: key) + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.configureCalls).toEqual([ + { + path: "App.swift", + publishableKeyWiring: "custom", + startupBinding: "app-init", + }, + ]); + }); + test("ignores authentication symbols inside Swift regex literals", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 5760a5072..a269074ca 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -10,6 +10,15 @@ import type { const MAX_SWIFT_FILE_BYTES = 1_000_000; +const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; +const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; +const CLERK_NATIVE_AUTH_FLOW = + /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; +const CLERK_ENVIRONMENT_INJECTION = + /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; +const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; +const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; + function blankRange(chars: string[], start: number, end: number): void { for (let i = start; i < end; i++) { if (chars[i] !== "\n" && chars[i] !== "\r") chars[i] = " "; @@ -78,6 +87,17 @@ function consumeRegexLiteral( return { end: chars.length, complete: false }; } +function containsClerkEvidence(source: string): boolean { + return [ + CLERK_CONFIGURE_CALL, + CLERK_URL_HANDLER, + CLERK_NATIVE_AUTH_FLOW, + CLERK_ENVIRONMENT_INJECTION, + CLERK_ENVIRONMENT_CONSUMER, + CLERK_AUTH_VIEW, + ].some((pattern) => pattern.test(source)); +} + /** * Removes comments and string contents while preserving offsets and newlines. * This is intentionally a small lexer rather than a regex: Swift supports @@ -151,11 +171,15 @@ function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSaniti chars[quoteIndex] === '"' && chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; i = quoteIndex + (multiline ? 3 : 1); let closed = false; + let interpolationDepth = 0; + let hasClosedInterpolation = false; while (i < chars.length) { - const closesQuote = multiline - ? chars[i] === '"' && chars[i + 1] === '"' && chars[i + 2] === '"' - : chars[i] === '"'; + const closesQuote = + interpolationDepth === 0 && + (multiline + ? chars[i] === '"' && chars[i + 1] === '"' && chars[i + 2] === '"' + : chars[i] === '"'); if (closesQuote) { const quoteLength = multiline ? 3 : 1; @@ -176,14 +200,29 @@ function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSaniti let escapeHashes = 0; while (chars[i + 1 + escapeHashes] === "#") escapeHashes++; if (escapeHashes === hashCount) { - i += 2 + escapeHashes; + const escapedCharacter = i + 1 + escapeHashes; + if (interpolationDepth === 0 && chars[escapedCharacter] === "(") { + interpolationDepth = 1; + } + i = escapedCharacter + 1; continue; } } + + if (interpolationDepth > 0) { + if (chars[i] === "(") interpolationDepth++; + if (chars[i] === ")") { + interpolationDepth--; + if (interpolationDepth === 0) hasClosedInterpolation = true; + } + } i++; } if (!closed) complete = false; + if (hasClosedInterpolation && containsClerkEvidence(chars.slice(start, i).join(""))) { + complete = false; + } if (blankStrings) blankRange(chars, start, i); } @@ -203,10 +242,6 @@ function has(source: string, pattern: RegExp): boolean { return pattern.test(source); } -const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; -const CLERK_NATIVE_AUTH_FLOW = - /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; - function matchingBrace(source: string, openingBrace: number): number | undefined { let depth = 0; for (let index = openingBrace; index < source.length; index++) { @@ -462,7 +497,7 @@ function configureCallEvidence( ): IOSConfigureCallEvidence[] { const calls: IOSConfigureCallEvidence[] = []; const initializerBodies = mainInitializerBodies(sanitizedSource); - const pattern = /\bClerk\s*\.\s*configure\s*\(/g; + const pattern = new RegExp(CLERK_CONFIGURE_CALL.source, "g"); let match: RegExpExecArray | null; while ((match = pattern.exec(sanitizedSource)) !== null) { @@ -602,17 +637,14 @@ export async function inspectSwiftSources( if (importsClerkModule) { configureCalls.push(...configureCallEvidence(sanitized, source, evidence)); } - if ( - importsClerkModule && - has(sanitized, /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/) - ) { + if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_INJECTION)) { environmentInjections.push(evidence); } - if (importsClerkModule && has(sanitized, /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/)) { + if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { environmentConsumers.push(evidence); } if ( - (importsUI && has(sanitized, /\bAuthView\s*\(/)) || + (importsUI && has(sanitized, CLERK_AUTH_VIEW)) || (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW)) ) { authFlowReferences.push(evidence); From c28cb9c865ddbdb82b1536b9e0389389c99a16c6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 17:12:27 -0400 Subject: [PATCH 34/45] fix(init): compare interpolated Clerk evidence --- .../src/commands/init/ios/swift.test.ts | 15 ++++- .../cli-core/src/commands/init/ios/swift.ts | 56 +++++++++---------- 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 0a3a2c5fa..3186bf90a 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -77,7 +77,7 @@ describe("sanitizeSwiftSource", () => { }); test("keeps ordinary string interpolation complete without hiding later Clerk calls", () => { - const source = String.raw`let message = "Hello \(user.name): \(format("%@", value))" + const source = String.raw`let message = "Hello \(user.name): \(format(value))" Clerk.configure(publishableKey: key)`; const result = sanitizeSwiftSourceWithStatus(source); @@ -88,6 +88,17 @@ Clerk.configure(publishableKey: key)`; expect(result.sanitizedSource).toContain("Clerk.configure(publishableKey: key)"); }); + test("keeps later Clerk calls visible after nested interpolation strings", () => { + const source = String.raw`let message = "Hello \("name)" + user.name)" +Clerk.configure(publishableKey: key) +let escaped = "say \"hello\""`; + + const result = sanitizeSwiftSourceWithStatus(source); + + expect(result.complete).toBe(true); + expect(result.sanitizedSource).toContain("Clerk.configure"); + }); + test("marks interpolation that contains executable Clerk evidence incomplete", () => { const configure = sanitizeSwiftSourceWithStatus( String.raw`let message = "configured: \(Clerk.configure(publishableKey: "pk_test_hidden"))"`, @@ -135,7 +146,7 @@ describe("inspectSwiftSources", () => { String.raw`import ClerkKit @main struct AppMain: App { init() { - let diagnostic = "Hello \(user.name): \(format("%@", value))" + let diagnostic = "Hello \(user.name): \(format(value))" Clerk.configure(publishableKey: key) } }`, diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index a269074ca..a56950da9 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -87,15 +87,23 @@ function consumeRegexLiteral( return { end: chars.length, complete: false }; } -function containsClerkEvidence(source: string): boolean { - return [ - CLERK_CONFIGURE_CALL, - CLERK_URL_HANDLER, - CLERK_NATIVE_AUTH_FLOW, - CLERK_ENVIRONMENT_INJECTION, - CLERK_ENVIRONMENT_CONSUMER, - CLERK_AUTH_VIEW, - ].some((pattern) => pattern.test(source)); +const CLERK_EVIDENCE_PATTERNS = [ + CLERK_CONFIGURE_CALL, + CLERK_URL_HANDLER, + CLERK_NATIVE_AUTH_FLOW, + CLERK_ENVIRONMENT_INJECTION, + CLERK_ENVIRONMENT_CONSUMER, + CLERK_AUTH_VIEW, +]; + +function evidenceCount(source: string, pattern: RegExp): number { + return source.match(new RegExp(pattern.source, "g"))?.length ?? 0; +} + +function hidesClerkEvidence(source: string, sanitizedSource: string): boolean { + return CLERK_EVIDENCE_PATTERNS.some( + (pattern) => evidenceCount(source, pattern) > evidenceCount(sanitizedSource, pattern), + ); } /** @@ -110,6 +118,7 @@ function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSaniti const chars = source.split(""); let i = 0; let complete = true; + let hasStringInterpolation = false; while (i < chars.length) { if (chars[i] === "/" && chars[i + 1] === "/") { @@ -171,15 +180,11 @@ function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSaniti chars[quoteIndex] === '"' && chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; i = quoteIndex + (multiline ? 3 : 1); let closed = false; - let interpolationDepth = 0; - let hasClosedInterpolation = false; while (i < chars.length) { - const closesQuote = - interpolationDepth === 0 && - (multiline - ? chars[i] === '"' && chars[i + 1] === '"' && chars[i + 2] === '"' - : chars[i] === '"'); + const closesQuote = multiline + ? chars[i] === '"' && chars[i + 1] === '"' && chars[i + 2] === '"' + : chars[i] === '"'; if (closesQuote) { const quoteLength = multiline ? 3 : 1; @@ -201,32 +206,21 @@ function sanitizeSwift(source: string, blankStrings: boolean): SwiftSourceSaniti while (chars[i + 1 + escapeHashes] === "#") escapeHashes++; if (escapeHashes === hashCount) { const escapedCharacter = i + 1 + escapeHashes; - if (interpolationDepth === 0 && chars[escapedCharacter] === "(") { - interpolationDepth = 1; - } + if (chars[escapedCharacter] === "(") hasStringInterpolation = true; i = escapedCharacter + 1; continue; } } - - if (interpolationDepth > 0) { - if (chars[i] === "(") interpolationDepth++; - if (chars[i] === ")") { - interpolationDepth--; - if (interpolationDepth === 0) hasClosedInterpolation = true; - } - } i++; } if (!closed) complete = false; - if (hasClosedInterpolation && containsClerkEvidence(chars.slice(start, i).join(""))) { - complete = false; - } if (blankStrings) blankRange(chars, start, i); } - return { sanitizedSource: chars.join(""), complete }; + const sanitizedSource = chars.join(""); + if (hasStringInterpolation && hidesClerkEvidence(source, sanitizedSource)) complete = false; + return { sanitizedSource, complete }; } export function sanitizeSwiftSource(source: string): string { From 83b744bf6a807642c852e5e8379156cf5014843b Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 18:07:09 -0400 Subject: [PATCH 35/45] fix(init): honor active Swift platform branches --- .../cli-core/src/commands/init/ios/inspect.ts | 1 + .../src/commands/init/ios/swift.test.ts | 125 ++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 108 ++++++++++++++- 3 files changed, 232 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index d198ac88d..707d15f41 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1004,6 +1004,7 @@ async function parseProject( targetSources.files.length > 0 ? await inspectSwiftSources(targetSources.files, { membershipComplete: targetSources.complete, + platform: "ios", }) : emptySwiftInspection(); if (targetSources.complete && !swiftInspection.evidenceComplete) { diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 3186bf90a..ece4ad7f8 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -115,6 +115,131 @@ let escaped = "say \"hello\""`; }); describe("inspectSwiftSources", () => { + test("uses the active macOS branch instead of iOS-only Clerk UI evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if os(iOS) + import ClerkKitUI + let authentication = AuthView() + #elseif os(macOS) + import ClerkKit + Clerk.configure(publishableKey: key) + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.importsClerkKit).toEqual([{ path: "Shared.swift" }]); + expect(inspection.configureCalls).toHaveLength(1); + }); + + test("uses the active iOS branch instead of macOS-only Clerk evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if os(macOS) + import ClerkKit + Clerk.configure(publishableKey: macKey) + #elseif os(iOS) + import ClerkKitUI + let authentication = AuthView() + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "ios" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.configureCalls).toEqual([]); + expect(inspection.importsClerkKitUI).toEqual([{ path: "Shared.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); + }); + + test("filters nested inactive platform branches", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if DEBUG + import ClerkKit + #if os(iOS) + Clerk.configure(publishableKey: iosKey) + #else + Clerk.configure(publishableKey: macKey) + #endif + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.configureCalls).toHaveLength(1); + }); + + test("keeps unknown conditional-compilation branches conservative", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if DEBUG + import ClerkKitUI + let authentication = AuthView() + #else + import ClerkKit + Clerk.configure(publishableKey: key) + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.importsClerkKitUI).toEqual([{ path: "Shared.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); + expect(inspection.configureCalls).toHaveLength(1); + }); + + test("marks malformed conditional-compilation structure incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if os(iOS) + import ClerkKitUI + let authentication = AuthView()`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.importsClerkKitUI).toEqual([{ path: "Shared.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); + }); + test("fails Clerk evidence closed when a configure call is hidden in interpolation", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index a56950da9..3a8d83729 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -548,6 +548,106 @@ function withoutPreviewOnlyRegions(source: string): string { return chars.join(""); } +type SwiftTargetPlatform = "ios" | "macos"; + +type PlatformCondition = boolean | "unknown"; + +interface ConditionalCompilationFrame { + parentCanExecute: boolean; + priorConditionIsAlwaysTrue: boolean; + sawElse: boolean; +} + +function evaluatePlatformCondition( + expression: string, + platform: SwiftTargetPlatform, +): PlatformCondition { + const match = /^os\s*\(\s*(iOS|macOS)\s*\)$/.exec(expression.trim()); + if (!match) return "unknown"; + return match[1] === (platform === "ios" ? "iOS" : "macOS"); +} + +/** + * Blanks only branches proven inactive for the selected Apple platform. + * Unknown and compound conditions remain visible so inspection stays + * conservative without trying to reproduce Swift's full compilation model. + */ +function withoutInactivePlatformRegions( + source: string, + platform: SwiftTargetPlatform, +): SwiftSourceSanitization { + const chars = source.split(""); + const stack: ConditionalCompilationFrame[] = []; + let currentCanExecute = true; + let cursor = 0; + let complete = true; + + while (cursor < source.length) { + const newline = source.indexOf("\n", cursor); + const lineEnd = newline === -1 ? source.length : newline; + const line = source.slice(cursor, lineEnd); + const directive = /^[ \t]*#(if|elseif|else|endif)\b(.*)$/.exec(line); + + if (!directive) { + if (!currentCanExecute) blankRange(chars, cursor, lineEnd); + cursor = newline === -1 ? source.length : newline + 1; + continue; + } + + const kind = directive[1]; + const expression = directive[2]?.trim() ?? ""; + if (kind === "if") { + if (!expression) { + complete = false; + break; + } + const condition = evaluatePlatformCondition(expression, platform); + stack.push({ + parentCanExecute: currentCanExecute, + priorConditionIsAlwaysTrue: condition === true, + sawElse: false, + }); + currentCanExecute = currentCanExecute && condition !== false; + } else if (kind === "elseif") { + const frame = stack.at(-1); + if (!frame || frame.sawElse || !expression) { + complete = false; + break; + } + const condition = evaluatePlatformCondition(expression, platform); + currentCanExecute = + frame.parentCanExecute && !frame.priorConditionIsAlwaysTrue && condition !== false; + frame.priorConditionIsAlwaysTrue ||= condition === true; + } else if (kind === "else") { + const frame = stack.at(-1); + if (!frame || frame.sawElse || expression) { + complete = false; + break; + } + frame.sawElse = true; + currentCanExecute = frame.parentCanExecute && !frame.priorConditionIsAlwaysTrue; + frame.priorConditionIsAlwaysTrue = true; + } else { + const frame = stack.pop(); + if (!frame || expression) { + complete = false; + break; + } + currentCanExecute = frame.parentCanExecute; + } + + // Keep directive lines intact. Besides preserving offsets, this lets + // mutation planners recognize that an entry point is conditionally built. + cursor = newline === -1 ? source.length : newline + 1; + } + + if (stack.length > 0) complete = false; + return { + sanitizedSource: complete ? chars.join("") : source, + complete, + }; +} + function hasClerkOpenURLHandler(source: string): boolean { const pattern = /\.\s*onOpenURL\b/g; let match: RegExpExecArray | null; @@ -582,7 +682,7 @@ function hasClerkOpenURLHandler(source: string): boolean { export async function inspectSwiftSources( sourceFiles: Array<{ absolutePath: string; relativePath: string }>, - options: { membershipComplete?: boolean } = {}, + options: { membershipComplete?: boolean; platform?: SwiftTargetPlatform } = {}, ): Promise { const entryPoints: IOSSourceEvidence[] = []; const importsClerkKit: IOSSourceEvidence[] = []; @@ -613,7 +713,11 @@ export async function inspectSwiftSources( sourceFilesScanned++; const structuralSource = sanitizeSwiftSourceWithStatus(source); if (!structuralSource.complete) evidenceComplete = false; - const sanitized = withoutPreviewOnlyRegions(structuralSource.sanitizedSource); + const platformSource = options.platform + ? withoutInactivePlatformRegions(structuralSource.sanitizedSource, options.platform) + : structuralSource; + if (!platformSource.complete) evidenceComplete = false; + const sanitized = withoutPreviewOnlyRegions(platformSource.sanitizedSource); const evidence = { path: file.relativePath }; const importsKit = has( sanitized, From 04aac9eb80aabfe8f0452e860204b24f55ab7fe4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 16:30:16 -0400 Subject: [PATCH 36/45] fix(ios): fail closed on malformed source membership --- .../src/commands/init/ios/inspect.test.ts | 22 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 19 +++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index c30b7983d..0fb3f87b7 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -339,6 +339,28 @@ describe("discoverIOSContainers", () => { }); describe("inspectIOSProject", () => { + test("marks malformed source-membership collections incomplete while preserving valid owners", async () => { + const root = await fixture({ complete: true, secondTarget: true }); + await transformProject(root, (objects) => { + objects[IOS_FIXTURE_IDS.secondTarget]!.buildPhases = IOS_FIXTURE_IDS.secondSourcesPhase; + objects[IOS_FIXTURE_IDS.secondSourcesPhase]!.files = IOS_FIXTURE_IDS.secondSourceBuildFile; + objects[IOS_FIXTURE_IDS.secondSourceBuildFile]!.fileRef = IOS_FIXTURE_IDS.appFile; + }); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const memberships = await inspectIOSSourceMembership(root); + const owners = memberships.filter((membership) => + membership.files.some((file) => file.relativePath === "MyApp/MyAppApp.swift"), + ); + const secondMembership = owners.find( + (membership) => membership.targetId === IOS_FIXTURE_IDS.secondTarget, + ); + + expect(owners).toHaveLength(2); + expect(secondMembership).toMatchObject({ complete: false }); + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(true); + }); + test("marks source membership incomplete when a group-relative file has multiple parents", async () => { const root = await fixture({ complete: true }); const alternateGroupId = "565656565656565656565656"; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 707d15f41..ab23d73b5 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -665,21 +665,30 @@ async function sourceFilesForTarget(options: { evidence: [{ path: projectEvidencePath, objectId }], }); }; - for (const phaseId of asStringArray(targetObject.buildPhases)) { + const requiredStringCollection = (value: unknown): string[] => { + if (Array.isArray(value)) { + const strings = value.filter((item): item is string => typeof item === "string"); + if (strings.length !== value.length) state.complete = false; + return strings; + } + + state.complete = false; + return typeof value === "string" ? [value] : []; + }; + const buildPhaseIds = requiredStringCollection(targetObject.buildPhases); + for (const phaseId of buildPhaseIds) { if (!objects[phaseId]) { reportDangling(phaseId, `Target ${targetId} contains a dangling build phase reference.`); } } const sourcePhaseIds = new Set( - asStringArray(targetObject.buildPhases).filter( - (phaseId) => objects[phaseId]?.isa === "PBXSourcesBuildPhase", - ), + buildPhaseIds.filter((phaseId) => objects[phaseId]?.isa === "PBXSourcesBuildPhase"), ); for (const phaseId of sourcePhaseIds) { const phase = objects[phaseId]; if (phase?.isa !== "PBXSourcesBuildPhase") continue; - for (const buildFileId of asStringArray(phase.files)) { + for (const buildFileId of requiredStringCollection(phase.files)) { const buildFile = objects[buildFileId]; if (!buildFile) { reportDangling( From 4d325baf9e3df290765a0cc53d968311a805c85e Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 17:35:02 -0400 Subject: [PATCH 37/45] fix(ios): validate Xcode source membership metadata --- .../src/commands/init/ios/inspect.test.ts | 89 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 11 ++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 0fb3f87b7..d2eee9504 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -361,6 +361,58 @@ describe("inspectIOSProject", () => { expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(true); }); + test.each([ + ["a scalar", "616161616161616161616161"], + ["a partially malformed array", ["616161616161616161616161", {}]], + ])( + "marks %s synchronized-group collection incomplete while preserving valid members", + async (_description, synchronizedGroups) => { + const root = await fixture({ complete: true }); + const synchronizedRootId = "616161616161616161616161"; + await mkdir(join(root, "Synced")); + await Bun.write( + join(root, "Synced", "SyncedApp.swift"), + 'import SwiftUI\n\n@main\nstruct SyncedApp: App {\n var body: some Scene { WindowGroup { Text("Synced") } }\n}\n', + ); + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "Synced", + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = synchronizedGroups; + }); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const target = inspection.appTargets[0]; + const membership = memberships.find( + (candidate) => candidate.targetId === IOS_FIXTURE_IDS.appTarget, + ); + + expect(target?.swift.sourceFilesScanned).toBe(2); + expect(target?.swift.entryPoints).toContainEqual({ path: "Synced/SyncedApp.swift" }); + expect(target?.swift.evidenceComplete).toBe(false); + expect(membership?.files).toContainEqual({ + absolutePath: join(root, "Synced", "SyncedApp.swift"), + relativePath: "Synced/SyncedApp.swift", + }); + expect(membership?.complete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-source-membership", + severity: "info", + evidence: [ + { + path: "MyApp.xcodeproj/project.pbxproj", + objectId: IOS_FIXTURE_IDS.appTarget, + }, + ], + }), + ); + }, + ); + test("marks source membership incomplete when a group-relative file has multiple parents", async () => { const root = await fixture({ complete: true }); const alternateGroupId = "565656565656565656565656"; @@ -1366,6 +1418,43 @@ struct MyApp: App { }, ); + test.each([ + ["an array", "( ios, )"], + ["an object", "{ value = ios; }"], + ])( + "does not use %s platformFilter value as authoritative iOS evidence", + async (_description, platformFilter) => { + const root = await fixture({ complete: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const original = await Bun.file(projectFile).text(); + await Bun.write( + projectFile, + original + .replace( + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef`, + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; platformFilter = ${platformFilter}; fileRef`, + ) + .replace( + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; productRef`, + `${IOS_FIXTURE_IDS.clerkKitBuildFile} = { isa = PBXBuildFile; platformFilter = ${platformFilter}; productRef`, + ), + ); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + + expect(target?.swift.sourceFilesScanned).toBe(0); + expect(target?.swift.evidenceComplete).toBe(false); + expect(target?.packages.clerkKit).toBe("declared"); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.incomplete-source-membership", + severity: "info", + }), + ); + }, + ); + test("reports malformed projects as blocked evidence", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-inspect-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index ab23d73b5..8ffe65cea 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -125,7 +125,11 @@ function buildFileIOSApplicability(object: PbxObject): { ) { return { applies: false, recognized: false }; } - const platformFilter = asString(object.platformFilter); + const rawFilter = object.platformFilter; + const platformFilter = asString(rawFilter); + if (Object.hasOwn(object, "platformFilter") && platformFilter == null) { + return { applies: false, recognized: false }; + } const filters = [...asStringArray(rawFilters), ...(platformFilter ? [platformFilter] : [])]; if (filters.length === 0) return { applies: true, recognized: true }; if (filters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter))) { @@ -745,7 +749,10 @@ async function sourceFilesForTarget(options: { } } - for (const groupId of asStringArray(targetObject.fileSystemSynchronizedGroups)) { + const synchronizedGroupIds = Object.hasOwn(targetObject, "fileSystemSynchronizedGroups") + ? requiredStringCollection(targetObject.fileSystemSynchronizedGroups) + : []; + for (const groupId of synchronizedGroupIds) { const group = objects[groupId]; if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") { reportDangling( From 256833b19b4b05cfd4f8f4cd5311a19eb06a4615 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 18:53:04 -0400 Subject: [PATCH 38/45] fix(ios): match entitlement bundle IDs case-insensitively --- .../src/commands/init/ios/inspect.test.ts | 18 ++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 3 ++- .../cli-core/src/lib/apple-native-identity.ts | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 packages/cli-core/src/lib/apple-native-identity.ts diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index d2eee9504..bc5d5150f 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -560,6 +560,24 @@ describe("inspectIOSProject", () => { }); }); + test("extracts the App ID Prefix when Bundle ID casing differs", async () => { + const root = await fixture({ complete: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await readFile(entitlementsPath, "utf8"); + await Bun.write( + entitlementsPath, + entitlements.replace("LEGACY1234.com.example.MyApp", "LEGACY1234.COM.EXAMPLE.MYAPP"), + ); + + const inspection = await inspectIOSProject(root); + + expect( + inspection.appTargets[0]?.configurations.map( + (configuration) => configuration.entitlements?.literalAppIdentifierPrefix, + ), + ).toEqual(["LEGACY1234", "LEGACY1234"]); + }); + test("does not attribute a Clerk product to an unrelated declared clerk-ios package", async () => { const root = await fixture({ clerkSDK: "core-only" }); const wrongPackageId = "272727272727272727272727"; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 8ffe65cea..76848a714 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,6 +1,7 @@ import { readdir } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; import { addBuildSettingConflictDiagnostics, inspectTargetBuildConfigurations, @@ -476,7 +477,7 @@ async function attachEntitlements( const literalAppIdentifierPrefix = prefixMatch && configuration.bundleIdentifier.state === "resolved" && - prefixMatch[2] === configuration.bundleIdentifier.value + bundleIdentifiersEqual(prefixMatch[2], configuration.bundleIdentifier.value) ? prefixMatch[1] : undefined; configuration.entitlements = { diff --git a/packages/cli-core/src/lib/apple-native-identity.ts b/packages/cli-core/src/lib/apple-native-identity.ts new file mode 100644 index 000000000..3e7645166 --- /dev/null +++ b/packages/cli-core/src/lib/apple-native-identity.ts @@ -0,0 +1,19 @@ +/** + * Apple treats Bundle IDs as case-insensitive. Keep the original spelling for + * display and API writes, but use this ASCII-only identity form anywhere a + * Bundle ID participates in matching or persisted retry identity. + */ +export function normalizeBundleIdentifierIdentity(bundleIdentifier: string): string { + return bundleIdentifier.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +export function bundleIdentifiersEqual( + left: string | undefined, + right: string | undefined, +): boolean { + return ( + left != null && + right != null && + normalizeBundleIdentifierIdentity(left) === normalizeBundleIdentifierIdentity(right) + ); +} From a7a7435fb407c5a6f7b5d43d7b28c575f5b47183 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 19:25:03 -0400 Subject: [PATCH 39/45] fix(ios): validate synchronized exclusions --- .../src/commands/init/ios/inspect.test.ts | 58 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 25 +++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index bc5d5150f..db0bb7069 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -413,6 +413,64 @@ describe("inspectIOSProject", () => { }, ); + test.each([ + ["a scalar group exception", "717171717171717171717171", ["Excluded.swift"]], + ["partially malformed group exceptions", ["717171717171717171717171", {}], ["Excluded.swift"]], + ["a scalar membership exception", ["717171717171717171717171"], "Excluded.swift"], + [ + "partially malformed membership exceptions", + ["717171717171717171717171"], + ["Excluded.swift", {}], + ], + ])( + "marks %s incomplete while preserving valid synchronized exclusions", + async (_description, groupExceptions, membershipExceptions) => { + const root = await fixture({ complete: true }); + const synchronizedRootId = "616161616161616161616161"; + const exceptionId = "717171717171717171717171"; + await mkdir(join(root, "Synced")); + await Bun.write( + join(root, "Synced", "Included.swift"), + "import ClerkKit\nstruct Included {}\n", + ); + await Bun.write( + join(root, "Synced", "Excluded.swift"), + "import ClerkKit\nstruct Excluded {}\n", + ); + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: groupExceptions, + path: "Synced", + sourceTree: "", + }; + objects[exceptionId] = { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + membershipExceptions, + target: IOS_FIXTURE_IDS.appTarget, + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const target = inspection.appTargets[0]; + const membership = memberships.find( + (candidate) => candidate.targetId === IOS_FIXTURE_IDS.appTarget, + ); + const synchronizedSources = membership?.files + .map((file) => file.relativePath) + .filter((path) => path.startsWith("Synced/")); + + expect(target?.swift.evidenceComplete).toBe(false); + expect(membership?.complete).toBe(false); + expect(synchronizedSources).toEqual(["Synced/Included.swift"]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + }, + ); + test("marks source membership incomplete when a group-relative file has multiple parents", async () => { const root = await fixture({ complete: true }); const alternateGroupId = "565656565656565656565656"; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 76848a714..6ce60ed08 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -545,14 +545,33 @@ function normalizeSynchronizedPath(path: string): string { return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); } +function synchronizedStringCollection( + object: PbxObject, + property: "exceptions" | "membershipExceptions", + state: { complete: boolean }, +): string[] { + if (!Object.hasOwn(object, property)) return []; + + const value = object[property]; + if (!Array.isArray(value)) { + state.complete = false; + return typeof value === "string" ? [value] : []; + } + + const strings = value.filter((item): item is string => typeof item === "string"); + if (strings.length !== value.length) state.complete = false; + return strings; +} + function synchronizedExclusions( group: PbxObject, targetId: string, relevantPhaseIds: Set, objects: PbxObjects, + state: { complete: boolean }, ): Set { const excluded = new Set(); - for (const exceptionId of asStringArray(group.exceptions)) { + for (const exceptionId of synchronizedStringCollection(group, "exceptions", state)) { const exception = objects[exceptionId]; const appliesToTarget = exception?.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet" && @@ -562,7 +581,7 @@ function synchronizedExclusions( relevantPhaseIds.has(asString(exception.buildPhase) ?? ""); if (!appliesToTarget && !appliesToPhase) continue; - for (const path of asStringArray(exception.membershipExceptions)) { + for (const path of synchronizedStringCollection(exception, "membershipExceptions", state)) { excluded.add(normalizeSynchronizedPath(path)); } if (isRecord(exception.platformFiltersByRelativePath)) { @@ -784,7 +803,7 @@ async function sourceFilesForTarget(options: { continue; } - const excluded = synchronizedExclusions(group, targetId, sourcePhaseIds, objects); + const excluded = synchronizedExclusions(group, targetId, sourcePhaseIds, objects, state); await collectSwiftFiles(root, groupPath, groupPath, excluded, files, state); } From e36b13ea6a26de64dfb99784200879dcbabd875a Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 20:23:41 -0400 Subject: [PATCH 40/45] fix(ios): validate synchronized exception records --- .../src/commands/init/ios/inspect.test.ts | 150 +++++++++++++++++- .../cli-core/src/commands/init/ios/inspect.ts | 44 ++++- 2 files changed, 185 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index db0bb7069..5abd9a50a 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { discoverIOSContainers, inspectWorkspace } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { recoverIOSFileTransactions } from "./file-transaction.ts"; -import type { PbxObjects } from "./pbx.ts"; +import type { PbxObject, PbxObjects } from "./pbx.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; @@ -471,6 +471,154 @@ describe("inspectIOSProject", () => { }, ); + test.each([ + ["a missing exception record", undefined], + ["an unknown exception record", { isa: "PBXFutureSynchronizedExceptionSet" }], + [ + "an exception record with an unusable target selector", + { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + membershipExceptions: ["Excluded.swift"], + target: {}, + }, + ], + [ + "an exception record with an unusable build-phase selector", + { + isa: "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet", + buildPhase: {}, + membershipExceptions: ["Excluded.swift"], + }, + ], + ] as Array<[string, PbxObject | undefined]>)( + "marks %s incomplete", + async (_description, exceptionRecord) => { + const root = await fixture({ complete: true }); + const synchronizedRootId = "616161616161616161616161"; + const exceptionId = "717171717171717171717171"; + await mkdir(join(root, "Synced")); + await Bun.write( + join(root, "Synced", "Included.swift"), + "import ClerkKit\nstruct Included {}\n", + ); + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: [exceptionId], + path: "Synced", + sourceTree: "", + }; + if (exceptionRecord) objects[exceptionId] = exceptionRecord; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const target = inspection.appTargets[0]; + const membership = memberships.find( + (candidate) => candidate.targetId === IOS_FIXTURE_IDS.appTarget, + ); + + expect(target?.swift.evidenceComplete).toBe(false); + expect(membership?.complete).toBe(false); + expect(membership?.files).toContainEqual({ + absolutePath: join(root, "Synced", "Included.swift"), + relativePath: "Synced/Included.swift", + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + }, + ); + + test.each([ + ["a non-record synchronized platform-filter map", ["Included.swift"]], + [ + "a synchronized platform-filter map with malformed entries", + { "Included.swift": ["macos", {}] }, + ], + ])("marks %s incomplete", async (_description, filtersByPath) => { + const root = await fixture({ complete: true }); + const synchronizedRootId = "616161616161616161616161"; + const exceptionId = "717171717171717171717171"; + await mkdir(join(root, "Synced")); + await Bun.write( + join(root, "Synced", "Included.swift"), + "import ClerkKit\nstruct Included {}\n", + ); + await Bun.write( + join(root, "Synced", "Excluded.swift"), + "import ClerkKit\nstruct Excluded {}\n", + ); + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: [exceptionId], + path: "Synced", + sourceTree: "", + }; + objects[exceptionId] = { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + membershipExceptions: ["Excluded.swift"], + platformFiltersByRelativePath: filtersByPath, + target: IOS_FIXTURE_IDS.appTarget, + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const target = inspection.appTargets[0]; + const membership = memberships.find( + (candidate) => candidate.targetId === IOS_FIXTURE_IDS.appTarget, + ); + const synchronizedSources = membership?.files + .map((file) => file.relativePath) + .filter((path) => path.startsWith("Synced/")); + + expect(target?.swift.evidenceComplete).toBe(false); + expect(membership?.complete).toBe(false); + expect(synchronizedSources).toEqual(["Synced/Included.swift"]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + }); + + test("ignores a valid synchronized exception for another target", async () => { + const root = await fixture({ complete: true }); + const synchronizedRootId = "616161616161616161616161"; + const exceptionId = "717171717171717171717171"; + await mkdir(join(root, "Synced")); + await Bun.write( + join(root, "Synced", "Included.swift"), + "import ClerkKit\nstruct Included {}\n", + ); + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: [exceptionId], + path: "Synced", + sourceTree: "", + }; + objects[exceptionId] = { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + membershipExceptions: ["Included.swift"], + target: "818181818181818181818181", + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + + expect(target?.swift.evidenceComplete).toBe(true); + expect(target?.swift.entryPoints).not.toEqual([]); + expect(target?.swift.sourceFilesScanned).toBe(2); + expect(inspection.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + }); + test("marks source membership incomplete when a group-relative file has multiple parents", async () => { const root = await fixture({ complete: true }); const alternateGroupId = "565656565656565656565656"; diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 6ce60ed08..f054f0ea1 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -573,20 +573,48 @@ function synchronizedExclusions( const excluded = new Set(); for (const exceptionId of synchronizedStringCollection(group, "exceptions", state)) { const exception = objects[exceptionId]; - const appliesToTarget = - exception?.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet" && - asString(exception.target) === targetId; - const appliesToPhase = - exception?.isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet" && - relevantPhaseIds.has(asString(exception.buildPhase) ?? ""); + if (!exception) { + state.complete = false; + continue; + } + + let appliesToTarget = false; + let appliesToPhase = false; + if (exception.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet") { + const exceptionTarget = exception.target; + if (typeof exceptionTarget !== "string" || exceptionTarget.length === 0) { + state.complete = false; + continue; + } + appliesToTarget = exceptionTarget === targetId; + } else if (exception.isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet") { + const buildPhase = exception.buildPhase; + if (typeof buildPhase !== "string" || buildPhase.length === 0) { + state.complete = false; + continue; + } + appliesToPhase = relevantPhaseIds.has(buildPhase); + } else { + state.complete = false; + continue; + } if (!appliesToTarget && !appliesToPhase) continue; for (const path of synchronizedStringCollection(exception, "membershipExceptions", state)) { excluded.add(normalizeSynchronizedPath(path)); } - if (isRecord(exception.platformFiltersByRelativePath)) { - for (const [path, filters] of Object.entries(exception.platformFiltersByRelativePath)) { + if (Object.hasOwn(exception, "platformFiltersByRelativePath")) { + const filtersByPath = exception.platformFiltersByRelativePath; + if (!isRecord(filtersByPath)) { + state.complete = false; + continue; + } + for (const [path, filters] of Object.entries(filtersByPath)) { const platformFilters = stringArray(filters); + if (!Array.isArray(filters) || platformFilters.length !== filters.length) { + state.complete = false; + continue; + } if ( platformFilters.length > 0 && !platformFilters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter)) From 47a83895a81e1c528dcf2a84995fc11dbb3bb4c1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 16:30:42 -0400 Subject: [PATCH 41/45] fix(init): reject malformed associated domains --- .../src/commands/init/ios/inspect.test.ts | 23 +++++++++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 19 ++++++++++++++- .../cli-core/src/commands/init/ios/types.ts | 1 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 5abd9a50a..f5bfd092e 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1029,6 +1029,29 @@ let package = Package( ); }); + test("rejects malformed Associated Domains entries instead of filtering them", async () => { + const root = await fixture({ complete: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await Bun.file(entitlementsPath).text(); + await Bun.write( + entitlementsPath, + entitlements.replace( + "webcredentials:clerk.example.test", + "webcredentials:clerk.example.test", + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toBeUndefined(); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.invalid-associated-domains", + message: expect.stringContaining("invalid Associated Domains"), + }), + ); + }); + test("resolves matching associated-domain variables across device and simulator contexts", async () => { const root = await fixture({ complete: true }); await addTargetBuildSettings(root, [ diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index f054f0ea1..efade2f27 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -366,6 +366,23 @@ async function inspectEntitlements( const parsed = parseIOSPlist(new TextDecoder().decode(bytes)); if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + const associatedDomainsKey = "com.apple.developer.associated-domains"; + const rawAssociatedDomains = parsed[associatedDomainsKey]; + if ( + Object.hasOwn(parsed, associatedDomainsKey) && + (!Array.isArray(rawAssociatedDomains) || + !rawAssociatedDomains.every((value): value is string => typeof value === "string")) + ) { + diagnostics.push({ + code: "xcode.invalid-associated-domains", + severity: "warning", + message: `${relativePath} has an invalid Associated Domains entitlement value.`, + remedy: `Set ${associatedDomainsKey} to an array containing only strings, then rerun the inspector.`, + evidence: [{ path: relativePath, keyPath: associatedDomainsKey }], + }); + return undefined; + } + const associatedDomains = Array.isArray(rawAssociatedDomains) ? rawAssociatedDomains : []; const applicationIdentifier = asString(parsed["application-identifier"]); const signInWithAppleState = appleEntitlementState(parsed); if (signInWithAppleState === "invalid") { @@ -379,7 +396,7 @@ async function inspectEntitlements( } return { path: relativePath, - associatedDomains: stringArray(parsed["com.apple.developer.associated-domains"]).sort(), + associatedDomains: associatedDomains.sort((left, right) => left.localeCompare(right)), unresolvedAssociatedDomains: [], applicationIdentifier, teamIdentifier: asString(parsed["com.apple.developer.team-identifier"]), diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index ddf0070eb..cf60ac50f 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -20,6 +20,7 @@ export interface IOSDiagnostic { | "xcode.conflicting-build-setting" | "xcode.missing-entitlements" | "xcode.unreadable-entitlements" + | "xcode.invalid-associated-domains" | "xcode.invalid-apple-entitlement" | "xcode.external-path" | "xcode.generated-project" From d9cc0effb65da905b40d40f263fc08f5c7291253 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 16:49:03 -0400 Subject: [PATCH 42/45] fix(init): preserve unrelated entitlement evidence --- packages/cli-core/src/commands/init/ios/inspect.test.ts | 7 ++++++- packages/cli-core/src/commands/init/ios/inspect.ts | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index f5bfd092e..7925c0c16 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1040,10 +1040,15 @@ let package = Package( "webcredentials:clerk.example.test", ), ); + await addAppleEntitlement(root, "Default"); const inspection = await inspectIOSProject(root); - expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toBeUndefined(); + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: [], + signInWithAppleState: "exact", + signInWithApple: true, + }); expect(inspection.diagnostics).toContainEqual( expect.objectContaining({ code: "xcode.invalid-associated-domains", diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index efade2f27..14f777d82 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -380,9 +380,12 @@ async function inspectEntitlements( remedy: `Set ${associatedDomainsKey} to an array containing only strings, then rerun the inspector.`, evidence: [{ path: relativePath, keyPath: associatedDomainsKey }], }); - return undefined; } - const associatedDomains = Array.isArray(rawAssociatedDomains) ? rawAssociatedDomains : []; + const associatedDomains = + Array.isArray(rawAssociatedDomains) && + rawAssociatedDomains.every((value): value is string => typeof value === "string") + ? rawAssociatedDomains + : []; const applicationIdentifier = asString(parsed["application-identifier"]); const signInWithAppleState = appleEntitlementState(parsed); if (signInWithAppleState === "invalid") { From b44751b5477c93dae165da7cbd2a0090416c68b6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 17:40:14 -0400 Subject: [PATCH 43/45] fix(init): preserve Swift division assignment --- .../src/commands/init/ios/swift.test.ts | 31 +++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 1 + 2 files changed, 32 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index ece4ad7f8..d6581cd70 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -76,6 +76,17 @@ describe("sanitizeSwiftSource", () => { expect(interpolated.sanitizedSource).not.toContain("Clerk.shared"); }); + test("does not parse division assignment as a regex literal", () => { + const source = `var value = 4 +value /= 2 +Clerk.configure(publishableKey: key)`; + + const result = sanitizeSwiftSourceWithStatus(source); + + expect(result.complete).toBe(true); + expect(result.sanitizedSource).toBe(source); + }); + test("keeps ordinary string interpolation complete without hiding later Clerk calls", () => { const source = String.raw`let message = "Hello \(user.name): \(format(value))" Clerk.configure(publishableKey: key)`; @@ -115,6 +126,26 @@ let escaped = "say \"hello\""`; }); describe("inspectSwiftSources", () => { + test("keeps source evidence complete across division assignment", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit +var value = 4 +value /= 2 +Clerk.configure(publishableKey: key)`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.configureCalls).toHaveLength(1); + }); + test("uses the active macOS branch instead of iOS-only Clerk UI evidence", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 3a8d83729..456d5e7ed 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -43,6 +43,7 @@ function isBareRegexOpening(chars: string[], index: number): boolean { if (first == null || first === " " || first === "\t" || first === "\n" || first === "\r") { return false; } + if (first === "=") return false; if (index === 0) return true; const previous = chars[index - 1]; From 1caada1bf0583f5a512a287264e845983ef8797a Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 18:37:35 -0400 Subject: [PATCH 44/45] fix(init): distrust conditional Swift evidence --- .../src/commands/init/ios/swift.test.ts | 110 ++++++++++++-- .../cli-core/src/commands/init/ios/swift.ts | 134 +++++++++++++----- 2 files changed, 204 insertions(+), 40 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index d6581cd70..353ee8ace 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -199,7 +199,7 @@ Clerk.configure(publishableKey: key)`, expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); }); - test("filters nested inactive platform branches", async () => { + test("does not prove Clerk setup nested under an unknown condition", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); const path = join(root, "Shared.swift"); @@ -220,11 +220,11 @@ Clerk.configure(publishableKey: key)`, { platform: "macos" }, ); - expect(inspection.evidenceComplete).toBe(true); - expect(inspection.configureCalls).toHaveLength(1); + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.configureCalls).toEqual([]); }); - test("keeps unknown conditional-compilation branches conservative", async () => { + test("does not use unknown conditional-compilation branches as positive evidence", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); const path = join(root, "Shared.swift"); @@ -244,12 +244,106 @@ Clerk.configure(publishableKey: key)`, { platform: "macos" }, ); + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); + expect(inspection.configureCalls).toEqual([]); + }); + + test("does not prove iOS-only AuthView code for a macOS target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if canImport(UIKit) + import ClerkKitUI + let authentication = AuthView() + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + expect(inspection.evidenceComplete).toBe(true); - expect(inspection.importsClerkKitUI).toEqual([{ path: "Shared.swift" }]); - expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); + }); + + test("does not prove Clerk setup behind negated or compound conditions", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + + for (const [index, condition] of ["!os(iOS)", "os(iOS) || os(macOS)"].entries()) { + const path = join(root, `Shared${index}.swift`); + await Bun.write( + path, + `#if ${condition} + import ClerkKitUI + let authentication = AuthView() + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: `Shared${index}.swift` }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); + } + }); + + test("keeps unrelated conditional logging from invalidating unconditional Clerk setup", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `import ClerkKit + Clerk.configure(publishableKey: key) + #if DEBUG + print(Clerk.shared.user) + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.importsClerkKit).toEqual([{ path: "Shared.swift" }]); expect(inspection.configureCalls).toHaveLength(1); }); + test("ignores unknown conditions nested inside a proven inactive branch", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); + temporaryDirectories.push(root); + const path = join(root, "Shared.swift"); + await Bun.write( + path, + `#if os(iOS) + #if DEBUG && canImport(UIKit) + import ClerkKitUI + let authentication = AuthView() + #endif + #endif`, + ); + + const inspection = await inspectSwiftSources( + [{ absolutePath: path, relativePath: "Shared.swift" }], + { platform: "macos" }, + ); + + expect(inspection.evidenceComplete).toBe(true); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); + }); + test("marks malformed conditional-compilation structure incomplete", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); @@ -267,8 +361,8 @@ Clerk.configure(publishableKey: key)`, ); expect(inspection.evidenceComplete).toBe(false); - expect(inspection.importsClerkKitUI).toEqual([{ path: "Shared.swift" }]); - expect(inspection.authFlowReferences).toEqual([{ path: "Shared.swift" }]); + expect(inspection.importsClerkKitUI).toEqual([]); + expect(inspection.authFlowReferences).toEqual([]); }); test("fails Clerk evidence closed when a configure call is hidden in interpolation", async () => { diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 456d5e7ed..2dce01844 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -18,6 +18,10 @@ const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; +const CLERK_KIT_IMPORT = + /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKit\b/; +const CLERK_KIT_UI_IMPORT = + /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKitUI\b/; function blankRange(chars: string[], start: number, end: number): void { for (let i = start; i < end; i++) { @@ -552,34 +556,63 @@ function withoutPreviewOnlyRegions(source: string): string { type SwiftTargetPlatform = "ios" | "macos"; type PlatformCondition = boolean | "unknown"; +type ConditionalExecution = "active" | "inactive" | "unknown"; interface ConditionalCompilationFrame { - parentCanExecute: boolean; - priorConditionIsAlwaysTrue: boolean; + parentExecution: ConditionalExecution; + priorCondition: PlatformCondition; sawElse: boolean; } +interface PlatformSourceSanitization extends SwiftSourceSanitization { + uncertainSource: string; +} + function evaluatePlatformCondition( expression: string, platform: SwiftTargetPlatform, ): PlatformCondition { - const match = /^os\s*\(\s*(iOS|macOS)\s*\)$/.exec(expression.trim()); - if (!match) return "unknown"; - return match[1] === (platform === "ios" ? "iOS" : "macOS"); + const normalized = expression.trim(); + const osMatch = /^os\s*\(\s*(iOS|macOS)\s*\)$/.exec(normalized); + if (osMatch) return osMatch[1] === (platform === "ios" ? "iOS" : "macOS"); + + const importMatch = /^canImport\s*\(\s*(UIKit|AppKit)\s*\)$/.exec(normalized); + if (importMatch) return importMatch[1] === (platform === "ios" ? "UIKit" : "AppKit"); + + return "unknown"; +} + +function executionForCondition( + parent: ConditionalExecution, + condition: PlatformCondition, +): ConditionalExecution { + if (parent === "inactive" || condition === false) return "inactive"; + if (parent === "active" && condition === true) return "active"; + return "unknown"; +} + +function combinedPriorCondition( + previous: PlatformCondition, + current: PlatformCondition, +): PlatformCondition { + if (previous === true || current === true) return true; + if (previous === "unknown" || current === "unknown") return "unknown"; + return false; } /** - * Blanks only branches proven inactive for the selected Apple platform. - * Unknown and compound conditions remain visible so inspection stays - * conservative without trying to reproduce Swift's full compilation model. + * Keeps only branches proven active for the selected Apple platform. Unknown + * branches are returned separately so they can invalidate relevant evidence + * without making unrelated conditional logging block inspection. */ function withoutInactivePlatformRegions( source: string, platform: SwiftTargetPlatform, -): SwiftSourceSanitization { +): PlatformSourceSanitization { const chars = source.split(""); + const uncertainChars = source.split(""); const stack: ConditionalCompilationFrame[] = []; - let currentCanExecute = true; + let currentExecution: ConditionalExecution = "active"; let cursor = 0; let complete = true; @@ -590,11 +623,14 @@ function withoutInactivePlatformRegions( const directive = /^[ \t]*#(if|elseif|else|endif)\b(.*)$/.exec(line); if (!directive) { - if (!currentCanExecute) blankRange(chars, cursor, lineEnd); + if (currentExecution !== "active") blankRange(chars, cursor, lineEnd); + if (currentExecution !== "unknown") blankRange(uncertainChars, cursor, lineEnd); cursor = newline === -1 ? source.length : newline + 1; continue; } + blankRange(uncertainChars, cursor, lineEnd); + const kind = directive[1]; const expression = directive[2]?.trim() ?? ""; if (kind === "if") { @@ -604,11 +640,11 @@ function withoutInactivePlatformRegions( } const condition = evaluatePlatformCondition(expression, platform); stack.push({ - parentCanExecute: currentCanExecute, - priorConditionIsAlwaysTrue: condition === true, + parentExecution: currentExecution, + priorCondition: condition, sawElse: false, }); - currentCanExecute = currentCanExecute && condition !== false; + currentExecution = executionForCondition(currentExecution, condition); } else if (kind === "elseif") { const frame = stack.at(-1); if (!frame || frame.sawElse || !expression) { @@ -616,9 +652,15 @@ function withoutInactivePlatformRegions( break; } const condition = evaluatePlatformCondition(expression, platform); - currentCanExecute = - frame.parentCanExecute && !frame.priorConditionIsAlwaysTrue && condition !== false; - frame.priorConditionIsAlwaysTrue ||= condition === true; + currentExecution = + frame.priorCondition === true + ? "inactive" + : frame.priorCondition === false + ? executionForCondition(frame.parentExecution, condition) + : condition === false || frame.parentExecution === "inactive" + ? "inactive" + : "unknown"; + frame.priorCondition = combinedPriorCondition(frame.priorCondition, condition); } else if (kind === "else") { const frame = stack.at(-1); if (!frame || frame.sawElse || expression) { @@ -626,15 +668,22 @@ function withoutInactivePlatformRegions( break; } frame.sawElse = true; - currentCanExecute = frame.parentCanExecute && !frame.priorConditionIsAlwaysTrue; - frame.priorConditionIsAlwaysTrue = true; + currentExecution = + frame.priorCondition === true + ? "inactive" + : frame.priorCondition === false + ? frame.parentExecution + : frame.parentExecution === "inactive" + ? "inactive" + : "unknown"; + frame.priorCondition = true; } else { const frame = stack.pop(); if (!frame || expression) { complete = false; break; } - currentCanExecute = frame.parentCanExecute; + currentExecution = frame.parentExecution; } // Keep directive lines intact. Besides preserving offsets, this lets @@ -643,12 +692,33 @@ function withoutInactivePlatformRegions( } if (stack.length > 0) complete = false; + if (!complete) { + blankRange(chars, 0, chars.length); + blankRange(uncertainChars, 0, uncertainChars.length); + } return { - sanitizedSource: complete ? chars.join("") : source, + sanitizedSource: chars.join(""), + uncertainSource: uncertainChars.join(""), complete, }; } +const CONDITIONAL_SETUP_EVIDENCE = [ + /@main\b/, + CLERK_KIT_IMPORT, + CLERK_KIT_UI_IMPORT, + /\bUserButton\s*\(/, + ...CLERK_EVIDENCE_PATTERNS, +]; + +function hasConditionalSetupEvidence(source: string, importsClerkModule: boolean): boolean { + if (CONDITIONAL_SETUP_EVIDENCE.some((pattern) => has(source, pattern))) return true; + return ( + importsClerkModule && + has(source, /\.\s*auth\s*\.\s*(?:signIn(?:With\w+)?|signUp(?:With\w+)?|startHostedAuth)\s*\(/) + ); +} + function hasClerkOpenURLHandler(source: string): boolean { const pattern = /\.\s*onOpenURL\b/g; let match: RegExpExecArray | null; @@ -716,19 +786,19 @@ export async function inspectSwiftSources( if (!structuralSource.complete) evidenceComplete = false; const platformSource = options.platform ? withoutInactivePlatformRegions(structuralSource.sanitizedSource, options.platform) - : structuralSource; - if (!platformSource.complete) evidenceComplete = false; - const sanitized = withoutPreviewOnlyRegions(platformSource.sanitizedSource); - const evidence = { path: file.relativePath }; - const importsKit = has( - sanitized, - /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKit\b/, - ); - const importsUI = has( - sanitized, - /\bimport\s+(?:(?:typealias|struct|class|enum|protocol|actor|let|var|func|macro)\s+)?ClerkKitUI\b/, + : undefined; + if (platformSource && !platformSource.complete) evidenceComplete = false; + const sanitized = withoutPreviewOnlyRegions( + platformSource?.sanitizedSource ?? structuralSource.sanitizedSource, ); + const uncertain = platformSource + ? withoutPreviewOnlyRegions(platformSource.uncertainSource) + : ""; + const evidence = { path: file.relativePath }; + const importsKit = has(sanitized, CLERK_KIT_IMPORT); + const importsUI = has(sanitized, CLERK_KIT_UI_IMPORT); const importsClerkModule = importsKit || importsUI; + if (hasConditionalSetupEvidence(uncertain, importsClerkModule)) evidenceComplete = false; if (has(sanitized, /@main\b/)) entryPoints.push(evidence); if (importsKit) importsClerkKit.push(evidence); From 2229d9adf2d3bb4538b1362a4bd53fe0bc9f68cd Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 19:01:57 -0400 Subject: [PATCH 45/45] docs(changeset): add native Apple project inspection --- .changeset/ios-project-inspector.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ios-project-inspector.md diff --git a/.changeset/ios-project-inspector.md b/.changeset/ios-project-inspector.md new file mode 100644 index 000000000..a1a887d5a --- /dev/null +++ b/.changeset/ios-project-inspector.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add safe Xcode project inspection for native Apple setup.