From ecf8dd30682cf42b1696d19696f87469b8fdbf65 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 21 Aug 2026 16:52:19 -0400 Subject: [PATCH 01/29] feat(init): add transactional local iOS setup --- .../init/ios/associated-domain.test.ts | 461 +++ .../commands/init/ios/associated-domain.ts | 1044 +++++++ .../commands/init/ios/direct-config.test.ts | 663 ++++ .../src/commands/init/ios/direct-config.ts | 1778 +++++++++++ .../init/ios/entitlements-settings.test.ts | 488 +++ .../init/ios/entitlements-settings.ts | 1498 +++++++++ .../src/commands/init/ios/install-sdk.test.ts | 745 +++++ .../src/commands/init/ios/install-sdk.ts | 1508 +++++++++ .../src/commands/init/ios/runtime-key.test.ts | 1053 +++++++ .../src/commands/init/ios/runtime-key.ts | 2753 +++++++++++++++++ 10 files changed, 11991 insertions(+) create mode 100644 packages/cli-core/src/commands/init/ios/associated-domain.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/associated-domain.ts create mode 100644 packages/cli-core/src/commands/init/ios/direct-config.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/direct-config.ts create mode 100644 packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/entitlements-settings.ts create mode 100644 packages/cli-core/src/commands/init/ios/install-sdk.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/install-sdk.ts create mode 100644 packages/cli-core/src/commands/init/ios/runtime-key.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/runtime-key.ts diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts new file mode 100644 index 000000000..c726be40b --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -0,0 +1,461 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, link, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { + applyIOSAssociatedDomain, + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, +} from "./associated-domain.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import type { PbxObjects } from "./pbx.ts"; + +const temporaryDirectories: string[] = []; +const HOST = "direct.clerk.example"; +const KEY = `pk_test_${Buffer.from(`${HOST}$`).toString("base64")}`; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-associated-domain-")); + temporaryDirectories.push(root); + return root; +} + +function directSource(key = KEY): string { + return `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${key}") + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`; +} + +async function directFixture( + options: Parameters[1] = {}, +): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, { ...options, includeKey: false }); + await Bun.write(join(root, "MyApp", "MyAppApp.swift"), directSource()); + return root; +} + +function planOptions(root: string, deferToPublishableKey = false) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + deferToPublishableKey, + }; +} + +async function removeAssociatedDomains(root: string, newline = "\n"): Promise { + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = [ + '', + '', + '', + "", + "\t", + "\tapplication-identifier", + "\tLEGACY1234.com.example.MyApp", + "\tcom.apple.developer.team-identifier", + "\tABCDE12345", + "", + "", + "", + ].join(newline); + await writeFile(path, source); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS Associated Domains setup", () => { + test("creates and attaches an iOS-only entitlements file for a synchronized multiplatform target", async () => { + const root = await directFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const path = join(root, "MyApp", "MyApp.entitlements"); + + const plan = await planIOSAssociatedDomain({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], + missingEntitlementsSettings: { + status: "ready", + buildSettingPath: "MyApp/MyApp.entitlements", + }, + }); + expect(JSON.stringify(plan)).not.toContain(KEY); + + const result = await applyIOSAssociatedDomain(plan); + expect(result.status).toBe("applied"); + const entitlements = await readFile(path, "utf8"); + expect(entitlements).toContain(`webcredentials:${HOST}`); + expect(entitlements).not.toContain("application-identifier"); + expect((await lstat(path)).mode & 0o7777).toBe(0o644); + + const archive = parsePbxProject( + await readFile(join(root, "MyApp.xcodeproj", "project.pbxproj"), "utf8"), + ) as unknown as { objects: PbxObjects }; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = archive.objects[id]!.buildSettings as Record; + expect(settings.CODE_SIGN_ENTITLEMENTS).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBe("MyApp/MyApp.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBe( + "MyApp/MyApp.entitlements", + ); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.mac.entitlements"); + } + + const digest = await treeDigest(root); + const rerun = await planIOSAssociatedDomain(planOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSAssociatedDomain(rerun)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("plans and applies the exact domain to an existing XML entitlements file", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + await removeAssociatedDomains(root, "\r\n"); + await chmod(path, 0o640); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan).toMatchObject({ + status: "ready", + expectedDomain: `webcredentials:${HOST}`, + requiresPublishableKey: false, + files: [{ path: "MyApp/MyApp.entitlements" }], + }); + const result = await applyIOSAssociatedDomain(plan); + const source = await readFile(path, "utf8"); + expect(result.status).toBe("applied"); + expect(source).toContain(`\t\twebcredentials:${HOST}`); + expect(source).toContain(""); + expect(source).toContain("\r\n"); + expect((await lstat(path)).mode & 0o7777).toBe(0o640); + expect(JSON.stringify({ plan, result })).not.toContain(KEY); + + const digest = await treeDigest(root); + const secondPlan = await planIOSAssociatedDomain(planOptions(root)); + expect(secondPlan.status).toBe("satisfied"); + expect((await applyIOSAssociatedDomain(secondPlan)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("adds a bare entry while preserving Apple's developer-mode entry", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", `webcredentials:${HOST}?mode=developer`), + ); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + const updated = await readFile(path, "utf8"); + + expect(plan.status).toBe("ready"); + expect(result.status).toBe("applied"); + expect(updated).toContain(`webcredentials:${HOST}?mode=developer`); + expect(updated).toContain(`webcredentials:${HOST}`); + }); + + test("preserves a multiline nonempty array's closing line and indentation", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const compact = + "com.apple.developer.associated-domainswebcredentials:clerk.example.test"; + const existingBlock = [ + "\tcom.apple.developer.associated-domains", + "\t", + "\t\twebcredentials:clerk.example.test", + "\t", + ].join("\n"); + const source = (await readFile(path, "utf8")).replace(compact, existingBlock); + await writeFile(path, source); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + const expectedBlock = existingBlock.replace( + "\t", + `\t\twebcredentials:${HOST}\n\t`, + ); + + expect(plan.status).toBe("ready"); + expect(result.status).toBe("applied"); + expect(await readFile(path, "utf8")).toBe(source.replace(existingBlock, expectedBlock)); + }); + + test("patches every distinct existing entitlements file", async () => { + const root = await directFixture(); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await removeAssociatedDomains(root); + await writeFile( + join(root, "MyApp", "MyApp-Release.entitlements"), + (await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8")).replace( + "preserve this comment", + "release comment", + ), + ); + const project = await readFile(projectPath, "utf8"); + const releaseMarker = `${IOS_FIXTURE_IDS.targetRelease} = { isa = XCBuildConfiguration;`; + const releaseStart = project.indexOf(releaseMarker); + expect(releaseStart).toBeGreaterThan(-1); + const nextObject = project.indexOf("\n ", releaseStart + releaseMarker.length); + const releaseObject = project.slice(releaseStart, nextObject); + await writeFile( + projectPath, + `${project.slice(0, releaseStart)}${releaseObject.replace( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp-Release.entitlements;", + )}${project.slice(nextObject)}`, + ); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + + expect(plan.files.map((file) => file.path)).toEqual([ + "MyApp/MyApp-Release.entitlements", + "MyApp/MyApp.entitlements", + ]); + expect(result.status).toBe("applied"); + for (const file of plan.files) { + expect(await readFile(join(root, file.path), "utf8")).toContain(`webcredentials:${HOST}`); + } + }); + + test("blocks distinct selected-target paths that hardlink the same entitlements file", async () => { + const root = await directFixture(); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const debugEntitlements = join(root, "MyApp", "MyApp.entitlements"); + const releaseEntitlements = join(root, "MyApp", "MyApp-Release.entitlements"); + await removeAssociatedDomains(root); + await link(debugEntitlements, releaseEntitlements); + const project = await readFile(projectPath, "utf8"); + const releaseMarker = `${IOS_FIXTURE_IDS.targetRelease} = { isa = XCBuildConfiguration;`; + const releaseStart = project.indexOf(releaseMarker); + expect(releaseStart).toBeGreaterThan(-1); + const nextObject = project.indexOf("\n ", releaseStart + releaseMarker.length); + const releaseObject = project.slice(releaseStart, nextObject); + await writeFile( + projectPath, + `${project.slice(0, releaseStart)}${releaseObject.replace( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp-Release.entitlements;", + )}${project.slice(nextObject)}`, + ); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks an entitlements file referenced by a target in another Xcode project", async () => { + const root = await directFixture(); + const secondaryRoot = join(root, "Secondary"); + const secondaryProjectPath = join(secondaryRoot, "MyApp.xcodeproj", "project.pbxproj"); + const secondaryTargetId = "919191919191919191919191"; + await createIOSFixture(secondaryRoot, { includeKey: false }); + const secondaryProject = (await readFile(secondaryProjectPath, "utf8")) + .replaceAll(IOS_FIXTURE_IDS.appTarget, secondaryTargetId) + .replaceAll( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = ../MyApp/MyApp.entitlements;", + ); + await writeFile(secondaryProjectPath, secondaryProject); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks nested selected projects owned by XcodeGen or Tuist", async () => { + for (const [marker, contents] of [ + ["project.yml", "name: MyApp\n"], + ["Project.swift", "import ProjectDescription\n"], + ] as const) { + const root = await temporaryRoot(); + const nestedRoot = join(root, "ios"); + await createIOSFixture(nestedRoot, { includeKey: false }); + await writeFile(join(nestedRoot, "MyApp", "MyAppApp.swift"), directSource()); + await writeFile(join(nestedRoot, marker), contents); + const before = await treeDigest(root); + + const plan = await planIOSAssociatedDomain({ + root, + projectPath: "ios/MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "generated-project" })); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("preauthorizes a redacted deferred host for the aggregate direct-config transaction", async () => { + const root = await temporaryRoot(); + await createIOSFixture(root, { includeKey: false }); + await removeAssociatedDomains(root); + + const plan = await planIOSAssociatedDomain(planOptions(root, true)); + const prepared = await prepareIOSAssociatedDomainMutation(plan, KEY); + + expect(plan).toMatchObject({ + status: "ready", + requiresPublishableKey: true, + }); + expect(plan.expectedDomain).toBeUndefined(); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify({ plan, prepared })).not.toContain(KEY); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + }); + + test("returns stale when the selected target's inline key host changes after planning", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + await removeAssociatedDomains(root); + const originalEntitlements = await readFile(path, "utf8"); + const plan = await planIOSAssociatedDomain(planOptions(root)); + const newerKey = `pk_test_${Buffer.from("newer.clerk.example$").toString("base64")}`; + await writeFile(join(root, "MyApp", "MyAppApp.swift"), directSource(newerKey)); + + const prepared = await prepareIOSAssociatedDomainMutation(plan); + + expect(prepared.status).toBe("stale"); + expect(await readFile(path, "utf8")).toBe(originalEntitlements); + }); + + test("blocks mixed, malformed, binary, and symlinked entitlements without writing", async () => { + const mixed = await directFixture({ releaseEntitlements: false }); + expect((await planIOSAssociatedDomain(planOptions(mixed))).blockers[0]?.code).toBe( + "mixed-entitlements", + ); + + const malformed = await directFixture(); + await writeFile(join(malformed, "MyApp", "MyApp.entitlements"), ""); + expect((await planIOSAssociatedDomain(planOptions(malformed))).blockers[0]?.code).toBe( + "unreadable-entitlements", + ); + + const binary = await directFixture(); + await writeFile(join(binary, "MyApp", "MyApp.entitlements"), "bplist00not-real"); + expect((await planIOSAssociatedDomain(planOptions(binary))).blockers[0]?.code).toBe( + "unsupported-entitlements", + ); + + const linked = await directFixture(); + const target = join(linked, "MyApp", "MyApp.entitlements"); + const real = join(linked, "MyApp", "Real.entitlements"); + await writeFile(real, await readFile(target)); + await rm(target); + await symlink(real, target); + const before = await treeDigest(linked); + expect((await planIOSAssociatedDomain(planOptions(linked))).blockers[0]?.code).toBe( + "unsupported-entitlements", + ); + expect(await treeDigest(linked)).toEqual(before); + }); + + test("blocks an entity-encoded Associated Domains key without rewriting it", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const encoded = (await readFile(path, "utf8")).replace( + "com.apple.developer.associated-domains", + "com.apple.developer.associated-domains", + ); + await writeFile(path, encoded); + const before = await treeDigest(root); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual( + expect.objectContaining({ code: "unsupported-entitlements" }), + ); + expect(result.status).toBe("blocked"); + expect(await readFile(path, "utf8")).toBe(encoded); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks literal and entity-encoded duplicate Associated Domains keys", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const literal = + "com.apple.developer.associated-domainswebcredentials:clerk.example.test"; + const encoded = + "com.apple.developer.associated-domainsapplinks:preserve.example"; + const source = (await readFile(path, "utf8")).replace(literal, `${encoded}${literal}`); + await writeFile(path, source); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual( + expect.objectContaining({ code: "unsupported-entitlements" }), + ); + expect(await readFile(path, "utf8")).toBe(source); + }); + + test("blocks an entitlements file shared by another native target", async () => { + const root = await directFixture({ secondTarget: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await removeAssociatedDomains(root); + let project = await readFile(projectPath, "utf8"); + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + const marker = `${id} = { isa = XCBuildConfiguration; buildSettings = { `; + project = project.replace( + marker, + `${marker}CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; `, + ); + } + await writeFile(projectPath, project); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + + test("returns stale and preserves newer bytes", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + await removeAssociatedDomains(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + await writeFile(path, "newer user bytes\n"); + + const result = await applyIOSAssociatedDomain(plan); + + expect(result.status).toBe("stale"); + expect(await readFile(path, "utf8")).toBe("newer user bytes\n"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts new file mode 100644 index 000000000..871e45b3f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -0,0 +1,1044 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { inspectTargetBuildConfigurations } from "./build-settings.ts"; +import { + discoverIOSContainers, + inspectWorkspace, + pathIsSafelyWithinIOSRoot, + relativeIOSPath, +} from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + planIOSMissingEntitlementsSettings, + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { + asString, + asStringArray, + buildPbxParentIndex, + isRecord, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; +import type { IOSAppTarget, IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; + +const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains"; +const MAX_ENTITLEMENTS_BYTES = 1_000_000; + +export type IOSAssociatedDomainBlockerCode = + | "invalid-selection" + | "generated-project" + | "runtime-key-unproven" + | "missing-entitlements" + | "mixed-entitlements" + | "unresolved-entitlements" + | "unsafe-entitlements" + | "unreadable-entitlements" + | "unsupported-entitlements" + | "shared-entitlements" + | "stale-entitlements"; + +export interface IOSAssociatedDomainBlocker { + code: IOSAssociatedDomainBlockerCode; + message: string; +} + +export interface IOSAssociatedDomainPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface IOSAssociatedDomainPlan { + schemaVersion: 1; + kind: "clerk-ios-associated-domain"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + targetName?: string; + /** Public Frontend API hostname only. A publishable key is never retained. */ + expectedDomain?: string; + /** True when the exact domain will be derived from the in-memory development key after auth. */ + requiresPublishableKey: boolean; + files: IOSAssociatedDomainPlanFile[]; + /** PBX settings needed only when the target has no entitlements file yet. */ + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + actions: string[]; + blockers: IOSAssociatedDomainBlocker[]; +} + +export interface IOSAssociatedDomainPlanOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; + /** A separately proven direct Swift configuration will supply the runtime key after auth. */ + deferToPublishableKey?: boolean; + /** Allows the strict synchronized-root planner to create and attach a new file. */ + allowMissingEntitlementsCreation?: boolean; +} + +export type PreparedIOSAssociatedDomainMutation = + | { + status: "satisfied"; + plan: IOSAssociatedDomainPlan; + expectedDomain: string; + } + | { status: "blocked"; plan: IOSAssociatedDomainPlan } + | { status: "stale"; plan: IOSAssociatedDomainPlan } + | { + status: "ready"; + plan: IOSAssociatedDomainPlan; + expectedDomain: string; + /** @internal Candidate bytes must never be serialized into output or telemetry. */ + mutations: IOSFileMutation[]; + /** True when mutations contains the caller's PBX candidate after semantic composition. */ + consumesBasePbxMutation: boolean; + }; + +export interface IOSAssociatedDomainApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSAssociatedDomainPlan; + message?: string; +} + +interface EntitlementsFile { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + domains: string[]; +} + +function blocker( + code: IOSAssociatedDomainBlockerCode, + message: string, +): IOSAssociatedDomainBlocker { + return { code, message }; +} + +function blockedPlan( + options: IOSAssociatedDomainPlanOptions, + blockers: IOSAssociatedDomainBlocker[], + targetName?: string, +): IOSAssociatedDomainPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: "blocked", + root: resolve(options.root), + projectPath: options.projectPath, + targetId: options.targetId, + ...(targetName ? { targetName } : {}), + requiresPublishableKey: options.deferToPublishableKey === true, + files: [], + actions: [], + blockers, + }; +} + +function selectedTarget( + inspection: IOSProjectInspectionResult, + projectPath: string, + targetId: string, +): IOSAppTarget | undefined { + const selection = inspection.selection; + if ( + selection.state !== "selected" || + selection.projectPath !== projectPath || + selection.targetId !== targetId + ) { + return undefined; + } + return inspection.appTargets.find( + (target) => target.projectPath === projectPath && target.id === targetId, + ); +} + +function runtimeFrontendHost( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, +): string | undefined { + const key = inspection.localPublishableKey; + if (!key.found || key.conflict || !key.source || !key.frontendApiHost) return undefined; + const source = key.source; + const connected = target.swift.configureCalls.some((call) => { + if (call.startupBinding !== "app-init") return false; + if (call.publishableKeyWiring === "inline-literal") { + return call.path === source && call.inlinePublishableKey?.state === "valid"; + } + if (call.publishableKeyWiring === "local-secrets-loader") { + return ( + call.localSecretsRuntimeBinding === "proven" && + target.runtimeKeySinks.some((sink) => sink.path === source) + ); + } + return call.publishableKeyWiring === "process-info-environment" && source.endsWith(".xcscheme"); + }); + return connected ? key.frontendApiHost : undefined; +} + +function stripXMLCommentsPreservingOffsets(source: string): string { + return source.replace(//g, (comment) => " ".repeat(comment.length)); +} + +function countAssociatedDomainKeys(source: string): number { + const structural = stripXMLCommentsPreservingOffsets(source); + return [ + ...structural.matchAll(/]*>\s*com\.apple\.developer\.associated-domains\s*<\/key>/g), + ].length; +} + +function decodeXMLText(value: string): string | undefined { + if (/[<>]/.test(value)) return undefined; + let unsupported = false; + const decoded = value.replace( + /&(?:#x([0-9a-f]+)|#([0-9]+)|(amp|lt|gt|quot|apos));/gi, + (_entity, hex: string | undefined, decimal: string | undefined, named: string | undefined) => { + if (hex) return String.fromCodePoint(Number.parseInt(hex, 16)); + if (decimal) return String.fromCodePoint(Number.parseInt(decimal, 10)); + if (named === "amp") return "&"; + if (named === "lt") return "<"; + if (named === "gt") return ">"; + if (named === "quot") return '"'; + if (named === "apos") return "'"; + unsupported = true; + return ""; + }, + ); + if (unsupported || /&[^;\s]*;/.test(decoded)) return undefined; + return decoded; +} + +function associatedDomainKeyStructure(source: string): { + semanticCount: number; + safelyDecoded: boolean; +} { + const structural = stripXMLCommentsPreservingOffsets(source); + let semanticCount = 0; + let safelyDecoded = true; + for (const match of structural.matchAll(/]*>([\s\S]*?)<\/key>/g)) { + const decoded = decodeXMLText(match[1] ?? ""); + if (decoded == null) { + safelyDecoded = false; + continue; + } + if (decoded.trim() === ASSOCIATED_DOMAINS_KEY) semanticCount += 1; + } + return { semanticCount, safelyDecoded }; +} + +function hasUnresolvedDomain(value: string): boolean { + return /\$\([^)]+\)|\$\{[^}]+\}/.test(value); +} + +async function inspectEntitlementsFile( + root: string, + absolutePath: string, +): Promise<{ file?: EntitlementsFile; blocker?: IOSAssociatedDomainBlocker }> { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_ENTITLEMENTS_BYTES) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + const bytes = new Uint8Array(await readFile(absolutePath)); + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} is a binary plist. Save it as XML before automatic setup.`, + ), + }; + } + const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + const textBytes = bom ? bytes.slice(3) : bytes; + const source = new TextDecoder("utf-8", { fatal: true }).decode(textBytes); + const parsed = parseIOSPlist(source); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + const rawDomains = parsed[ASSOCIATED_DOMAINS_KEY]; + const structuralKeyCount = countAssociatedDomainKeys(source); + const semanticKeyStructure = associatedDomainKeyStructure(source); + if ( + rawDomains !== undefined && + (!Array.isArray(rawDomains) || rawDomains.some((value) => typeof value !== "string")) + ) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} has a non-string Associated Domains value.`, + ), + }; + } + if ( + !semanticKeyStructure.safelyDecoded || + semanticKeyStructure.semanticCount > 1 || + structuralKeyCount > 1 || + (rawDomains !== undefined && + (structuralKeyCount !== 1 || semanticKeyStructure.semanticCount !== 1)) || + (rawDomains === undefined && + (structuralKeyCount !== 0 || semanticKeyStructure.semanticCount !== 0)) + ) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} does not contain one safely editable literal Associated Domains key.`, + ), + }; + } + const domains = (rawDomains as string[] | undefined) ?? []; + if (domains.some(hasUnresolvedDomain)) { + return { + blocker: blocker( + "unresolved-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} contains Associated Domains entries with unresolved build settings.`, + ), + }; + } + return { + file: { + absolutePath, + relativePath: relativeIOSPath(root, absolutePath), + bytes, + hash: hashIOSFileBytes(bytes), + mode: info.mode & 0o7777, + source, + bom, + domains, + }, + }; + } catch { + return { + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +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 as PbxObject; + } + return objects; +} + +async function ownershipIsExclusive( + root: string, + projectPath: string, + selectedTargetId: string, + selectedFiles: readonly EntitlementsFile[], +): Promise { + try { + const selectedCanonical = new Set(); + const selectedInodes = new Set(); + for (const file of selectedFiles) { + const canonical = await realpath(file.absolutePath); + const info = await lstat(file.absolutePath); + const inode = `${info.dev}:${info.ino}`; + // Two selected configuration paths that resolve to the same file are + // not independent transaction targets. Refuse both symlink/canonical + // aliases and hard-link aliases rather than silently splitting them. + if (selectedCanonical.has(canonical) || selectedInodes.has(inode)) return false; + selectedCanonical.add(canonical); + selectedInodes.add(inode); + } + + const selectedProject = resolve(root, projectPath); + const discovered = await discoverIOSContainers(root); + const projectPaths = new Set([...discovered.projectPaths, selectedProject]); + for (const workspacePath of discovered.workspacePaths) { + const workspace = await inspectWorkspace(root, workspacePath); + for (const localProjectPath of workspace.localProjectPaths) { + projectPaths.add(localProjectPath); + } + } + for (const absoluteProject of [...projectPaths].sort()) { + const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + const bytes = new Uint8Array(await readFile(pbxprojPath)); + if (bytes.byteLength > 15_000_000) return false; + const archive = parsePbxProject(new TextDecoder().decode(bytes)); + const objects = normalizeObjects(archive.objects); + if (!objects) return false; + const rootObjectId = asString(archive.rootObject); + const projectObject = + (rootObjectId ? objects[rootObjectId] : undefined) ?? + Object.values(objects).find((object) => object.isa === "PBXProject"); + if (projectObject?.isa !== "PBXProject") return false; + const parents = buildPbxParentIndex(objects); + const groupRootDirectory = resolve( + dirname(absoluteProject), + asString(projectObject.projectDirPath) ?? "", + ); + + for (const targetId of asStringArray(projectObject.targets)) { + if (absoluteProject === selectedProject && targetId === selectedTargetId) continue; + const targetObject = objects[targetId]; + if (targetObject?.isa !== "PBXNativeTarget") continue; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics, + }); + if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return false; + for (const configuration of configurations) { + const resolution = configuration.model.entitlementsPath; + if (resolution.state === "unresolved") return false; + if (resolution.state !== "resolved") continue; + const siblingPath = resolve(dirname(absoluteProject), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + try { + const canonical = await realpath(siblingPath); + const info = await lstat(siblingPath); + if (selectedCanonical.has(canonical) || selectedInodes.has(`${info.dev}:${info.ino}`)) { + return false; + } + } catch { + // A missing sibling entitlements path cannot currently alias an existing selected file. + } + } + } + } + return true; + } catch { + return false; + } +} + +function exactDomainPresent(domains: readonly string[], expectedDomain: string): boolean { + return domains.includes(expectedDomain); +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [relativePath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, relativePath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +/** + * Plans the conservative v1 Associated Domains edit. It only patches existing + * XML entitlements files that cover every selected-target configuration. + */ +export async function planIOSAssociatedDomain( + options: IOSAssociatedDomainPlanOptions, +): Promise { + const root = resolve(options.root); + const inspection = await inspectIOSProject(root, { + target: options.targetId, + }); + const target = selectedTarget(inspection, options.projectPath, options.targetId); + if (!target) { + return blockedPlan(options, [ + blocker("invalid-selection", "The selected iOS target could not be resolved exactly."), + ]); + } + const generator = + inspection.generatedProject ?? + (await generatedProjectKind(root, resolve(root, options.projectPath))); + if (generator) { + return blockedPlan( + options, + [ + blocker( + "generated-project", + `This is a ${ + generator === "xcodegen" ? "XcodeGen" : "Tuist" + } project; update its source manifest instead of generated entitlements.`, + ), + ], + target.name, + ); + } + + const host = runtimeFrontendHost(inspection, target); + if (!host && !options.deferToPublishableKey) { + return blockedPlan( + options, + [ + blocker( + "runtime-key-unproven", + "The exact Frontend API host is not connected to a proven selected-target runtime key.", + ), + ], + target.name, + ); + } + + if (target.configurations.length === 0) { + return blockedPlan( + options, + [ + blocker( + "missing-entitlements", + "The selected target has no inspectable build configurations.", + ), + ], + target.name, + ); + } + const expectedDomain = host ? `webcredentials:${host}` : undefined; + const resolvedPaths = target.configurations.flatMap((configuration) => + configuration.entitlementsPath.state === "resolved" + ? [configuration.entitlementsPath.value] + : [], + ); + if (resolvedPaths.length === 0) { + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "missing", + ) + ) { + return blockedPlan( + options, + [ + blocker( + "unresolved-entitlements", + "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", + ), + ], + target.name, + ); + } + if (options.allowMissingEntitlementsCreation) { + const settingsPlan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: options.projectPath, + targetId: options.targetId, + }); + if (settingsPlan.status === "ready" && settingsPlan.entitlementsPath) { + return { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: "ready", + root, + projectPath: options.projectPath, + targetId: options.targetId, + targetName: target.name, + ...(expectedDomain ? { expectedDomain } : {}), + requiresPublishableKey: expectedDomain == null, + files: [{ path: settingsPlan.entitlementsPath, operation: "create" }], + missingEntitlementsSettings: settingsPlan, + actions: [ + expectedDomain + ? `Create ${settingsPlan.entitlementsPath} with ${expectedDomain}.` + : `Create ${settingsPlan.entitlementsPath} with the linked development instance's exact webcredentials host (resolved after authentication).`, + `Attach ${settingsPlan.entitlementsPath} only to iPhone and iPad SDK builds for every selected-target configuration.`, + ], + blockers: [], + }; + } + return blockedPlan( + options, + settingsPlan.blockers.length > 0 + ? settingsPlan.blockers.map((item) => blocker("missing-entitlements", item.message)) + : [ + blocker( + "missing-entitlements", + "The missing-entitlements plan did not identify one safe destination.", + ), + ], + target.name, + ); + } + return blockedPlan( + options, + [ + blocker( + "missing-entitlements", + "No selected-target configuration has an existing entitlements file, and this runtime route cannot safely create one automatically.", + ), + ], + target.name, + ); + } + if (resolvedPaths.length !== target.configurations.length) { + return blockedPlan( + options, + [ + blocker( + "mixed-entitlements", + "Some selected-target configurations have entitlements while others do not. Choose the intended files in Xcode before automatic setup.", + ), + ], + target.name, + ); + } + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "resolved", + ) + ) { + return blockedPlan( + options, + [ + blocker( + "unresolved-entitlements", + "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", + ), + ], + target.name, + ); + } + + const filesByPath = new Map(); + const blockers: IOSAssociatedDomainBlocker[] = []; + for (const configuredPath of new Set(resolvedPaths)) { + const absolutePath = resolve(root, options.projectPath, "..", configuredPath); + const inspected = await inspectEntitlementsFile(root, absolutePath); + if (inspected.blocker) blockers.push(inspected.blocker); + if (inspected.file) filesByPath.set(inspected.file.absolutePath, inspected.file); + } + if (blockers.length > 0 || filesByPath.size !== new Set(resolvedPaths).size) { + return blockedPlan(options, blockers, target.name); + } + const files = [...filesByPath.values()].sort((a, b) => + a.relativePath.localeCompare(b.relativePath), + ); + if (!(await ownershipIsExclusive(root, options.projectPath, options.targetId, files))) { + return blockedPlan( + options, + [ + blocker( + "shared-entitlements", + "An entitlements file may be shared with another target, or exclusive ownership could not be proven.", + ), + ], + target.name, + ); + } + + const satisfied = + expectedDomain != null && + files.every((file) => exactDomainPresent(file.domains, expectedDomain)); + return { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: satisfied ? "satisfied" : "ready", + root, + projectPath: options.projectPath, + targetId: options.targetId, + targetName: target.name, + ...(expectedDomain ? { expectedDomain } : {}), + requiresPublishableKey: expectedDomain == null, + files: files.map((file) => ({ + path: file.relativePath, + operation: "modify" as const, + expectedHash: file.hash, + })), + actions: satisfied + ? [] + : [ + expectedDomain + ? `Add ${expectedDomain} to every selected-target entitlements configuration.` + : "Add the linked development instance's exact webcredentials host to every selected-target entitlements configuration (host resolved after authentication).", + ], + blockers: [], + }; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function lineIndentAt(source: string, index: number): string { + const start = source.lastIndexOf("\n", index - 1) + 1; + return /^[\t ]*/.exec(source.slice(start, index))?.[0] ?? ""; +} + +function addDomainToXML(source: string, expectedDomain: string): string | undefined { + const structural = stripXMLCommentsPreservingOffsets(source); + const keyMatches = [ + ...structural.matchAll(/]*>\s*com\.apple\.developer\.associated-domains\s*<\/key>/g), + ]; + if (keyMatches.length > 1) return undefined; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const encoded = xmlEscape(expectedDomain); + + const keyMatch = keyMatches[0]; + if (!keyMatch || keyMatch.index == null) { + const dictClose = structural.lastIndexOf(""); + if (dictClose < 0) return undefined; + const closingIndent = lineIndentAt(source, dictClose); + const firstKey = /${ASSOCIATED_DOMAINS_KEY}${newline}${childIndent}${newline}${childIndent}\t${encoded}${newline}${childIndent}${newline}`; + return `${source.slice(0, dictClose)}${insertion}${source.slice(dictClose)}`; + } + + const afterKey = keyMatch.index + keyMatch[0].length; + const tail = structural.slice(afterKey); + const selfClosing = /^\s*]*\/\s*>/.exec(tail); + if (selfClosing) { + const start = afterKey + (selfClosing.index ?? 0); + const end = start + selfClosing[0].length; + const keyIndent = lineIndentAt(source, keyMatch.index); + const replacement = `${newline}${keyIndent}${newline}${keyIndent}\t${encoded}${newline}${keyIndent}`; + return `${source.slice(0, start)}${replacement}${source.slice(end)}`; + } + const open = /^\s*]*>/.exec(tail); + if (!open) return undefined; + const arrayStart = afterKey + (open.index ?? 0); + const contentStart = arrayStart + open[0].length; + const closeOffset = structural.slice(contentStart).indexOf(""); + if (closeOffset < 0) return undefined; + const close = contentStart + closeOffset; + const arrayIndent = lineIndentAt(source, arrayStart); + const existingContent = source.slice(contentStart, close); + const closingLine = /\r?\n[\t ]*$/.exec(existingContent); + if (closingLine?.index != null) { + const insertionIndex = contentStart + closingLine.index; + const insertion = `${newline}${arrayIndent}\t${encoded}`; + return `${source.slice(0, insertionIndex)}${insertion}${source.slice(insertionIndex)}`; + } + // Preserve compact arrays as compact rather than moving their closing tag. + const insertion = `${encoded}`; + return `${source.slice(0, close)}${insertion}${source.slice(close)}`; +} + +function bytesWithOptionalBOM(source: string, bom: boolean): Uint8Array { + const encoded = new TextEncoder().encode(source); + if (!bom) return encoded; + const bytes = new Uint8Array(encoded.length + 3); + bytes.set([0xef, 0xbb, 0xbf]); + bytes.set(encoded, 3); + return bytes; +} + +function newEntitlementsBytes(expectedDomain: string): Uint8Array { + return new TextEncoder().encode( + [ + '', + '', + '', + "", + `\t${ASSOCIATED_DOMAINS_KEY}`, + "\t", + `\t\t${xmlEscape(expectedDomain)}`, + "\t", + "", + "", + "", + ].join("\n"), + ); +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +function preparedWithHiddenMutations( + plan: IOSAssociatedDomainPlan, + expectedDomain: string, + mutations: IOSFileMutation[], + consumesBasePbxMutation: boolean, +): Extract { + const result = { + status: "ready" as const, + plan, + expectedDomain, + consumesBasePbxMutation, + } as Extract; + Object.defineProperty(result, "mutations", { + value: mutations, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +export async function prepareIOSAssociatedDomainMutation( + plan: IOSAssociatedDomainPlan, + publishableKey?: string, + options: { basePbxMutation?: IOSExistingFileMutation } = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + let expectedDomain = plan.expectedDomain; + if (publishableKey) { + try { + const decoded = decodePublishableKey(publishableKey); + if (decoded.instanceType !== "development") return { status: "blocked", plan }; + const fromKey = `webcredentials:${decoded.fapiHost}`; + if (expectedDomain && expectedDomain !== fromKey) return { status: "blocked", plan }; + expectedDomain = fromKey; + } catch { + return { status: "blocked", plan }; + } + } + if (!expectedDomain || (plan.requiresPublishableKey && !publishableKey)) { + return { status: "blocked", plan }; + } + + // Compare the exact authorized bytes before reparsing them. A concurrent + // edit that also makes the plist malformed is still a stale plan, not a new + // structural blocker, and the newer bytes must remain untouched. + for (const plannedFile of plan.files) { + const absolutePath = resolve(plan.root, plannedFile.path); + if (plannedFile.operation === "create") { + try { + await lstat(absolutePath); + return { status: "stale", plan }; + } catch (error) { + if (!isMissingFileError(error)) return { status: "stale", plan }; + } + continue; + } + try { + if (!plannedFile.expectedHash) return { status: "blocked", plan }; + const info = await lstat(absolutePath); + if ( + !info.isFile() || + info.isSymbolicLink() || + hashIOSFileBytes(await readFile(absolutePath)) !== plannedFile.expectedHash + ) { + return { status: "stale", plan }; + } + } catch { + return { status: "stale", plan }; + } + } + + const replanned = await planIOSAssociatedDomain({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + deferToPublishableKey: plan.requiresPublishableKey, + allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if ( + replanned.status !== plan.status || + replanned.expectedDomain !== plan.expectedDomain || + replanned.requiresPublishableKey !== plan.requiresPublishableKey || + replanned.files.length !== plan.files.length || + replanned.files.some( + (file, index) => + file.path !== plan.files[index]?.path || + file.operation !== plan.files[index]?.operation || + file.expectedHash !== plan.files[index]?.expectedHash, + ) + ) { + return { status: "stale", plan }; + } + + if (plan.missingEntitlementsSettings) { + const plannedFile = plan.files[0]; + if ( + plan.files.length !== 1 || + plannedFile?.operation !== "create" || + plannedFile.path !== plan.missingEntitlementsSettings.entitlementsPath + ) { + return { status: "blocked", plan }; + } + const preparedSettings = await prepareIOSMissingEntitlementsSettingsMutation( + plan.missingEntitlementsSettings, + options.basePbxMutation, + ); + if (preparedSettings.status === "stale") return { status: "stale", plan }; + if (preparedSettings.status !== "ready") return { status: "blocked", plan }; + const expectedParentIdentity = + plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + const synchronizedRootPath = plan.missingEntitlementsSettings.synchronizedRootPath; + const createPath = resolve(plan.root, plannedFile.path); + if ( + !expectedParentIdentity || + !synchronizedRootPath || + dirname(createPath) !== resolve(plan.root, synchronizedRootPath) + ) { + return { status: "blocked", plan }; + } + const candidateBytes = newEntitlementsBytes(expectedDomain); + const createMutation: IOSCreateFileMutation = { + kind: "create", + path: createPath, + expectedParentIdentity: { ...expectedParentIdentity }, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: 0o644, + }; + return preparedWithHiddenMutations( + plan, + expectedDomain, + // Commit the harmless new plist before project.pbxproj starts pointing + // at it. The aggregate transaction still rolls both back on failure. + [createMutation, preparedSettings.mutation], + options.basePbxMutation != null, + ); + } + + const mutations: IOSExistingFileMutation[] = []; + for (const plannedFile of plan.files) { + if (plannedFile.operation !== "modify" || !plannedFile.expectedHash) { + return { status: "blocked", plan }; + } + const absolutePath = resolve(plan.root, plannedFile.path); + const inspected = await inspectEntitlementsFile(plan.root, absolutePath); + if (!inspected.file || inspected.file.hash !== plannedFile.expectedHash) { + return { status: "stale", plan }; + } + if (exactDomainPresent(inspected.file.domains, expectedDomain)) continue; + const candidateSource = addDomainToXML(inspected.file.source, expectedDomain); + if (!candidateSource) return { status: "blocked", plan }; + const candidateBytes = bytesWithOptionalBOM(candidateSource, inspected.file.bom); + mutations.push({ + path: inspected.file.absolutePath, + originalBytes: inspected.file.bytes, + originalHash: inspected.file.hash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: inspected.file.mode, + }); + } + if (mutations.length === 0) return { status: "satisfied", plan, expectedDomain }; + return preparedWithHiddenMutations(plan, expectedDomain, mutations, false); +} + +export async function validatePreparedIOSAssociatedDomain( + prepared: Extract, +): Promise { + if ( + prepared.plan.missingEntitlementsSettings && + !(await validateIOSMissingEntitlementsSettingsPostcondition( + prepared.plan.missingEntitlementsSettings, + )) + ) { + return false; + } + const inspection = await inspectIOSProject(prepared.plan.root, { + target: prepared.plan.targetId, + }); + const target = selectedTarget(inspection, prepared.plan.projectPath, prepared.plan.targetId); + if (!target) return false; + if ( + inspection.generatedProject != null || + (await generatedProjectKind( + prepared.plan.root, + resolve(prepared.plan.root, prepared.plan.projectPath), + )) != null + ) { + return false; + } + const expectedHost = prepared.expectedDomain.slice("webcredentials:".length); + if (runtimeFrontendHost(inspection, target) !== expectedHost) return false; + if (target.configurations.length === 0) return false; + const files: EntitlementsFile[] = []; + for (const configuration of target.configurations) { + if (configuration.entitlementsPath.state !== "resolved") return false; + const absolutePath = resolve( + prepared.plan.root, + prepared.plan.projectPath, + "..", + configuration.entitlementsPath.value, + ); + const inspected = await inspectEntitlementsFile(prepared.plan.root, absolutePath); + if (!inspected.file || !exactDomainPresent(inspected.file.domains, prepared.expectedDomain)) { + return false; + } + files.push(inspected.file); + } + return ownershipIsExclusive( + prepared.plan.root, + prepared.plan.projectPath, + prepared.plan.targetId, + [...new Map(files.map((file) => [file.absolutePath, file])).values()], + ); +} + +export async function applyIOSAssociatedDomain( + plan: IOSAssociatedDomainPlan, + publishableKey?: string, +): Promise { + const prepared = await prepareIOSAssociatedDomainMutation(plan, publishableKey); + if (prepared.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if (prepared.status === "stale") return { status: "stale", plan: prepared.plan }; + if (prepared.status === "satisfied") return { status: "satisfied", plan: prepared.plan }; + const result = await applyIOSFileTransaction(prepared.mutations, [ + async () => validatePreparedIOSAssociatedDomain(prepared), + ]); + return { status: result.status, plan: prepared.plan }; +} diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts new file mode 100644 index 000000000..16198eb24 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -0,0 +1,663 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyIOSDirectConfig, + planIOSDirectConfig, + prepareIOSDirectConfigMutation, + validatePreparedIOSDirectConfig, + type IOSDirectConfigBlockerCode, +} from "./direct-config.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; + +const DEVELOPMENT_KEY = `pk_test_${Buffer.from("direct-config.clerk.accounts.dev$").toString("base64")}`; +const OTHER_DEVELOPMENT_KEY = `pk_test_${Buffer.from("other-app.clerk.accounts.dev$").toString("base64")}`; +const PRODUCTION_KEY = `pk_live_${Buffer.from("production.example.com$").toString("base64")}`; +const temporaryDirectories: string[] = []; + +async function temporaryRoot(prefix = "clerk-ios-direct-config-"): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(root); + return root; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, options); + return root; +} + +function appSourcePath(root: string): string { + return join(root, "MyApp", "MyAppApp.swift"); +} + +function planOptions(root: string, targetId: string = IOS_FIXTURE_IDS.appTarget) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId, + }; +} + +async function source(root: string): Promise { + return readFile(appSourcePath(root), "utf8"); +} + +async function replaceSource(root: string, value: string | Uint8Array): Promise { + await writeFile(appSourcePath(root), value); +} + +function blockerCodes( + plan: Awaited>, +): IOSDirectConfigBlockerCode[] { + return plan.blockers.map((blocker) => blocker.code); +} + +async function run(...args: string[]): Promise { + const child = Bun.spawn(args, { stdout: "ignore", stderr: "ignore" }); + if ((await child.exited) !== 0) throw new Error(`Command failed: ${args[0]}`); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS direct Clerk configuration", () => { + test("plans a fully redacted pristine SwiftUI setup without writing", async () => { + const root = await fixture(); + const before = await treeDigest(root); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan).toMatchObject({ + status: "ready", + sourcePath: "MyApp/MyAppApp.swift", + changes: { + clerkKitImport: "insert", + configuration: "insert-initializer", + environment: "insert", + }, + blockers: [], + }); + expect(plan.actions).toHaveLength(3); + expect(JSON.stringify(plan)).not.toContain("pk_test_"); + expect(JSON.stringify(plan)).not.toContain(DEVELOPMENT_KEY); + expect(await treeDigest(root)).toEqual(before); + }); + + test("parses repeated Swift import attributes in linear time", async () => { + const root = await fixture(); + const attributes = "@A() ".repeat(2_000); + await replaceSource( + root, + `${attributes}import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("ready"); + expect(plan.changes?.clerkKitImport).toBe("insert"); + }); + + test("refuses an attributed ClerkKit import instead of adding a duplicate", async () => { + const root = await fixture(); + await replaceSource( + root, + `@_exported import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + + expect(blockerCodes(await planIOSDirectConfig(planOptions(root)))).toContain( + "unsupported-app-structure", + ); + }); + + test("configures a compact pristine app and is byte-idempotent", async () => { + const root = await fixture(); + const firstPlan = await planIOSDirectConfig(planOptions(root)); + + expect((await applyIOSDirectConfig(firstPlan, DEVELOPMENT_KEY)).status).toBe("applied"); + const configured = await source(root); + expect(configured).toContain("import SwiftUI\nimport ClerkKit\n"); + expect(configured).toContain(`Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}")`); + expect(configured).toContain('Text("Hello").environment(Clerk.shared)'); + + const secondPlan = await planIOSDirectConfig(planOptions(root)); + expect(secondPlan.changes).toEqual({ + clerkKitImport: "satisfied", + configuration: "verify-existing", + environment: "satisfied", + }); + const beforeSecondApply = await readFile(appSourcePath(root)); + expect((await applyIOSDirectConfig(secondPlan, DEVELOPMENT_KEY)).status).toBe("satisfied"); + expect(await readFile(appSourcePath(root))).toEqual(beforeSecondApply); + }); + + test("inserts configuration first in one existing initializer", async () => { + const root = await fixture(); + await replaceSource( + root, + `import SwiftUI + +@main +struct MyApp: App { + init() { + // Existing startup behavior. + bootstrap() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } + + private func bootstrap() {} +} +`, + ); + + const plan = await planIOSDirectConfig(planOptions(root)); + expect(plan.changes?.configuration).toBe("insert-statement"); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("applied"); + const configured = await source(root); + expect(configured.indexOf("Clerk.configure")).toBeLessThan(configured.indexOf("bootstrap()")); + expect(configured).toContain("// Existing startup behavior."); + expect(configured).toContain("private func bootstrap() {}"); + }); + + test("treats an existing exact inline literal as verification-required", async () => { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } +} +`, + ); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("ready"); + expect(plan.changes?.configuration).toBe("verify-existing"); + expect(plan.actions.join(" ")).toContain("Verify the existing inline Clerk configuration"); + expect(JSON.stringify(plan)).not.toContain(DEVELOPMENT_KEY); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("satisfied"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("refuses indirect Clerk access before an existing inline configuration", async () => { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + bootstrap() + Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } + + private func bootstrap() { consume(Clerk.shared) } + private func consume(_ clerk: Clerk) {} +} +`, + ); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(blockerCodes(plan)).toContain("preinitialization-clerk-access"); + expect(plan.blockers[0]?.message).toContain("first executable statement"); + }); + + test("refuses stored startup state even when an explicit initializer exists", async () => { + for (const initializer of [ + "init() { bootstrap() }", + `init() { Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") }`, + ]) { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +private func earlyClerk() -> Clerk { Clerk.shared } + +@main +struct MyApp: App { + let early = earlyClerk() + ${initializer} + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + private func bootstrap() {} +} +`, + ); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(blockerCodes(plan)).toContain("unsupported-initializer"); + expect(plan.blockers[0]?.message).toContain("stored startup state"); + } + }); + + test("preserves a different existing inline development key", async () => { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${OTHER_DEVELOPMENT_KEY}") } + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } +} +`, + ); + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY); + + expect(result.status).toBe("blocked"); + expect(result.plan.blockers[0]?.code).toBe("different-inline-publishable-key"); + expect(JSON.stringify(result)).not.toContain(DEVELOPMENT_KEY); + expect(JSON.stringify(result)).not.toContain(OTHER_DEVELOPMENT_KEY); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("refuses malformed, production, and indirect existing configurations", async () => { + const cases = [ + { + call: 'Clerk.configure(publishableKey: "pk_test_not-valid")', + code: "invalid-inline-publishable-key", + }, + { + call: `Clerk.configure(publishableKey: "${PRODUCTION_KEY}")`, + code: "production-inline-publishable-key", + }, + { + call: 'Clerk.configure(publishableKey: LocalSecrets.load().publishableKey ?? "")', + code: "conflicting-configuration", + }, + ] as const; + + for (const item of cases) { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { ${item.call} } + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + const plan = await planIOSDirectConfig(planOptions(root)); + expect(blockerCodes(plan)).toContain(item.code); + } + }); + + test("refuses multiple @main declarations and complex scene roots", async () => { + const multipleRoot = await fixture(); + await replaceSource( + multipleRoot, + `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } +} + +@main +struct OtherApp: App { + var body: some Scene { WindowGroup { Text("Other") } } +} +`, + ); + expect((await planIOSDirectConfig(planOptions(multipleRoot))).status).toBe("blocked"); + + const complexScene = await fixture(); + await replaceSource( + complexScene, + `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + Text("Second root") + } + } +} +`, + ); + expect(blockerCodes(await planIOSDirectConfig(planOptions(complexScene)))).toContain( + "unsupported-scene", + ); + }); + + test("refuses Clerk.shared access that can run before App initialization", async () => { + const globalAccess = await fixture(); + await replaceSource( + globalAccess, + `import ClerkKit +import SwiftUI + +let earlyClerk = Clerk.shared + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + expect(blockerCodes(await planIOSDirectConfig(planOptions(globalAccess)))).toContain( + "preinitialization-clerk-access", + ); + + const memberAccess = await fixture(); + await replaceSource( + memberAccess, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + let earlyClerk = Clerk.shared + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + expect(blockerCodes(await planIOSDirectConfig(planOptions(memberAccess)))).toContain( + "unsupported-initializer", + ); + + const beforeExistingConfig = await fixture(); + await replaceSource( + beforeExistingConfig, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + use(Clerk.shared) + Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") + } + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } +} +`, + ); + expect(blockerCodes(await planIOSDirectConfig(planOptions(beforeExistingConfig)))).toContain( + "preinitialization-clerk-access", + ); + }); + + test("does not mistake method-body or WindowGroup environment use for early access", async () => { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") + } + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + private func later() { use(Clerk.shared) } +} +`, + ); + + expect((await planIOSDirectConfig(planOptions(root))).status).toBe("ready"); + }); + + test("refuses generated projects and an unsafe external source path", async () => { + const generated = await fixture({ generated: "xcodegen" }); + expect(blockerCodes(await planIOSDirectConfig(planOptions(generated)))).toContain( + "generated-project", + ); + + const parentRoot = await temporaryRoot(); + const nestedRoot = join(parentRoot, "Nested"); + await createIOSFixture(nestedRoot); + await writeFile(join(nestedRoot, "project.yml"), "name: MyApp\n"); + expect( + blockerCodes( + await planIOSDirectConfig({ + root: parentRoot, + projectPath: "Nested/MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }), + ), + ).toContain("generated-project"); + + const externalRoot = await fixture(); + const outside = await temporaryRoot("clerk-ios-outside-"); + await writeFile(join(outside, "Outside.swift"), await source(externalRoot)); + await rm(appSourcePath(externalRoot)); + await symlink(join(outside, "Outside.swift"), appSourcePath(externalRoot)); + expect(blockerCodes(await planIOSDirectConfig(planOptions(externalRoot)))).toContain( + "incomplete-source-membership", + ); + }); + + test("edits only the explicitly selected target", async () => { + const root = await fixture({ secondTarget: true }); + const mainBefore = await readFile(appSourcePath(root)); + const adminPath = join(root, "AdminApp", "AdminAppApp.swift"); + + const plan = await planIOSDirectConfig(planOptions(root, IOS_FIXTURE_IDS.secondTarget)); + expect(plan.sourcePath).toBe("AdminApp/AdminAppApp.swift"); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("applied"); + + expect(await readFile(appSourcePath(root))).toEqual(mainBefore); + expect(await readFile(adminPath, "utf8")).toContain("Clerk.configure"); + }); + + test("detects stale source bytes before writing", async () => { + const root = await fixture(); + const plan = await planIOSDirectConfig(planOptions(root)); + await writeFile(appSourcePath(root), `${await source(root)}// Concurrent edit.\n`); + const changed = await readFile(appSourcePath(root)); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY); + + expect(result.status).toBe("stale"); + expect(await readFile(appSourcePath(root))).toEqual(changed); + }); + + test("detects a commit-time race without overwriting it", async () => { + const root = await fixture(); + const plan = await planIOSDirectConfig(planOptions(root)); + let raced = Buffer.alloc(0); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY, { + beforeCommit: async () => { + await writeFile(appSourcePath(root), `${await source(root)}// Commit race.\n`); + raced = await readFile(appSourcePath(root)); + }, + }); + + expect(result.status).toBe("stale"); + expect(await readFile(appSourcePath(root))).toEqual(raced); + }); + + test("rolls back an exact candidate after post-write validation fails", async () => { + const root = await fixture(); + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY, { + forcePostWriteValidationFailure: true, + }); + + expect(result.status).toBe("rolled-back"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("preserves CRLF, comments, file mode, and unrelated Swift bytes", async () => { + const root = await fixture(); + const crlf = [ + "// Keep this header.", + "import SwiftUI", + "", + "@main", + "struct MyApp: App {", + " var body: some Scene {", + " WindowGroup {", + " ContentView() // Keep this root comment.", + " }", + " }", + "", + " private func unrelated() {", + ' print("Leave me byte-identical.")', + " }", + "}", + "", + ].join("\r\n"); + await replaceSource(root, crlf); + await chmod(appSourcePath(root), 0o640); + + const plan = await planIOSDirectConfig(planOptions(root)); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("applied"); + const configuredBytes = await readFile(appSourcePath(root)); + const configured = configuredBytes.toString("utf8"); + expect(configured.replaceAll("\r\n", "")).not.toContain("\n"); + expect(configured).toContain("// Keep this header."); + expect(configured).toContain( + "ContentView().environment(Clerk.shared) // Keep this root comment.", + ); + expect(configured).toContain( + ' private func unrelated() {\r\n print("Leave me byte-identical.")\r\n }', + ); + expect((await lstat(appSourcePath(root))).mode & 0o777).toBe(0o640); + }); + + test("blocks a dirty planned Swift source unless explicitly allowed", async () => { + const root = await fixture(); + await run("git", "init", "-q", root); + await run("git", "-C", root, "config", "user.email", "test@example.com"); + await run("git", "-C", root, "config", "user.name", "Test User"); + await run("git", "-C", root, "add", "MyApp/MyAppApp.swift"); + await run("git", "-C", root, "commit", "-qm", "fixture"); + await writeFile(appSourcePath(root), `${await source(root)}// Dirty.\n`); + + expect(blockerCodes(await planIOSDirectConfig(planOptions(root)))).toContain("dirty-source"); + expect( + ( + await planIOSDirectConfig({ + ...planOptions(root), + allowDirty: true, + }) + ).status, + ).toBe("ready"); + }); + + test("allows dirty source when an exact inline setup only needs key verification", async () => { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${DEVELOPMENT_KEY}") } + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } +} +`, + ); + await run("git", "init", "-q", root); + await run("git", "-C", root, "config", "user.email", "test@example.com"); + await run("git", "-C", root, "config", "user.name", "Test User"); + await run("git", "-C", root, "add", "MyApp/MyAppApp.swift"); + await run("git", "-C", root, "commit", "-qm", "fixture"); + await writeFile(appSourcePath(root), `${await source(root)}// Dirty but not rewritten.\n`); + + const plan = await planIOSDirectConfig(planOptions(root)); + expect(plan.status).toBe("ready"); + expect(plan.changes).toEqual({ + clerkKitImport: "satisfied", + configuration: "verify-existing", + environment: "satisfied", + }); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("satisfied"); + }); + + test("prepares a non-enumerable in-memory mutation and validates an external commit", async () => { + const root = await fixture(); + const plan = await planIOSDirectConfig(planOptions(root)); + const prepared = await prepareIOSDirectConfigMutation(plan, DEVELOPMENT_KEY); + + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("Expected a prepared mutation."); + expect(prepared.mutation.candidateBytes.toString()).not.toBe(""); + expect(new TextDecoder().decode(prepared.mutation.candidateBytes)).toContain(DEVELOPMENT_KEY); + expect(JSON.stringify(prepared)).not.toContain(DEVELOPMENT_KEY); + expect(JSON.stringify(prepared.mutation)).not.toContain(DEVELOPMENT_KEY); + expect(await source(root)).not.toContain(DEVELOPMENT_KEY); + + await writeFile(prepared.mutation.absolutePath, prepared.mutation.candidateBytes); + await chmod(prepared.mutation.absolutePath, prepared.mutation.mode); + expect(await validatePreparedIOSDirectConfig(prepared)).toBe(true); + }); + + test("never includes a supplied key in ordinary apply results", async () => { + const root = await fixture(); + const plan = await planIOSDirectConfig(planOptions(root)); + + const invalidResult = await applyIOSDirectConfig(plan, "pk_test_do-not-print"); + expect(invalidResult.status).toBe("blocked"); + expect(JSON.stringify(invalidResult)).not.toContain("pk_test_do-not-print"); + + const productionResult = await applyIOSDirectConfig(plan, PRODUCTION_KEY); + expect(productionResult.status).toBe("blocked"); + expect(JSON.stringify(productionResult)).not.toContain(PRODUCTION_KEY); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts new file mode 100644 index 000000000..7ff3fc759 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -0,0 +1,1778 @@ +import { randomUUID } from "node:crypto"; +import { chmod, lstat, open, readFile, rename, rm } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { sanitizeSwiftSource } from "./swift.ts"; + +const MAX_SWIFT_FILE_BYTES = 1_000_000; + +export interface IOSDirectConfigPlanOptions { + root: string; + /** Project-root-relative path selected by the iOS inspector. */ + projectPath: string; + targetId: string; + /** Low-level escape hatch. The aggregate init flow also checks every local mutation. */ + allowDirty?: boolean; +} + +export type IOSDirectConfigBlockerCode = + | "invalid-selection" + | "external-path" + | "generated-project" + | "target-not-found" + | "incomplete-source-membership" + | "ambiguous-entry-point" + | "unreadable-source" + | "unsupported-encoding" + | "unsupported-line-endings" + | "unsupported-app-structure" + | "unsupported-initializer" + | "unsupported-scene" + | "conflicting-configuration" + | "conflicting-environment" + | "preinitialization-clerk-access" + | "invalid-inline-publishable-key" + | "production-inline-publishable-key" + | "dirty-source" + | "git-state-unknown" + | "invalid-publishable-key" + | "production-publishable-key" + | "different-inline-publishable-key"; + +export interface IOSDirectConfigBlocker { + code: IOSDirectConfigBlockerCode; + message: string; +} + +export interface IOSDirectConfigChanges { + clerkKitImport: "insert" | "satisfied"; + configuration: "insert-initializer" | "insert-statement" | "verify-existing"; + environment: "insert" | "satisfied"; +} + +/** + * A redacted, serializable plan. It deliberately contains neither a key nor + * candidate source bytes. A direct literal remains verification-required + * until prepare/apply receives the selected application's development key. + */ +export interface IOSDirectConfigPlan { + schemaVersion: 1; + kind: "clerk-ios-direct-config"; + status: "ready" | "blocked"; + root: string; + projectPath: string; + targetId: string; + allowDirty: boolean; + sourcePath?: string; + /** SHA-256 of the exact source bytes inspected by this plan. */ + expectedSourceHash?: string; + changes?: IOSDirectConfigChanges; + /** Semantic, publishable-key-redacted preview. */ + actions: string[]; + blockers: IOSDirectConfigBlocker[]; +} + +export interface IOSDirectConfigApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSDirectConfigPlan; + message?: string; +} + +/** @internal A key-bearing in-memory mutation for a multi-file transaction coordinator. */ +export interface IOSDirectConfigFileMutation { + absolutePath: string; + expectedHash: string; + candidateHash: string; + mode: number; + /** Non-enumerable at runtime so ordinary JSON serialization cannot emit source/key bytes. */ + originalBytes: Uint8Array; + /** Non-enumerable at runtime so ordinary JSON serialization cannot emit source/key bytes. */ + candidateBytes: Uint8Array; +} + +/** + * @internal Transaction-oriented preparation result. `mutation` is + * non-enumerable and may contain the raw publishable key in candidate bytes. + */ +export type IOSDirectConfigPreparedMutation = + | { + status: "ready"; + plan: IOSDirectConfigPlan; + mutation: IOSDirectConfigFileMutation; + } + | { + status: "satisfied" | "blocked" | "stale"; + plan: IOSDirectConfigPlan; + message?: string; + mutation?: undefined; + }; + +/** @internal Test-only fault injection for the standalone atomic writer. */ +export interface IOSDirectConfigApplyOptions { + beforeCommit?: () => void | Promise; + beforePostWriteValidation?: () => void | Promise; + forcePostWriteValidationFailure?: boolean; +} + +interface FileSnapshot { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + source: string; + hash: string; + mode: number; +} + +interface Range { + start: number; + end: number; +} + +interface AppStructure { + source: string; + sanitized: string; + newline: "\n" | "\r\n"; + appType: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; + initializer?: Range & { openingBrace: number; closingBrace: number }; + body: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; + root: Range & { modifierStarts: number[] }; + hasClerkKitImport: boolean; + importInsertion: number; + existingPublishableKey?: string; + hasEnvironment: boolean; + configurationInsertion: + | { kind: "new-initializer"; index: number; memberIndent: string; statementIndent: string } + | { kind: "existing-initializer"; index: number; statementIndent: string; multiline: boolean } + | { kind: "existing-literal" }; + environmentInsertion?: { index: number; textBeforeKey: string }; +} + +interface PreparedDirectConfig { + plan: IOSDirectConfigPlan; + snapshot?: FileSnapshot; + structure?: AppStructure; +} + +interface SourceEdit { + index: number; + text: string; +} + +interface StagedSource { + temporaryPath: string; + mutation: IOSDirectConfigFileMutation; + committed: boolean; +} + +const preparedValidators = new WeakMap Promise>(); + +function sha256(value: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +} + +function makePlan( + options: IOSDirectConfigPlanOptions, + root: string, + projectPath: string, + status: IOSDirectConfigPlan["status"], + details: Partial< + Pick< + IOSDirectConfigPlan, + "sourcePath" | "expectedSourceHash" | "changes" | "actions" | "blockers" + > + > = {}, +): IOSDirectConfigPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-direct-config", + status, + root, + projectPath, + targetId: options.targetId, + allowDirty: options.allowDirty === true, + sourcePath: details.sourcePath, + expectedSourceHash: details.expectedSourceHash, + changes: details.changes, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSDirectConfigPlanOptions, + root: string, + projectPath: string, + code: IOSDirectConfigBlockerCode, + message: string, + source: Partial = {}, +): PreparedDirectConfig { + return { + ...source, + plan: makePlan(options, root, projectPath, "blocked", { + sourcePath: source.plan?.sourcePath, + expectedSourceHash: source.plan?.expectedSourceHash, + blockers: [{ code, message }], + }), + }; +} + +function skipWhitespace(source: string, start: number, end = source.length): number { + let cursor = start; + while (cursor < end && /\s/.test(source[cursor] ?? "")) cursor += 1; + return cursor; +} + +function trimWhitespaceEnd(source: string, start: number, end: number): number { + let cursor = end; + while (cursor > start && /\s/.test(source[cursor - 1] ?? "")) cursor -= 1; + return cursor; +} + +function matchingDelimiter( + source: string, + opening: number, + openCharacter: "(" | "[" | "{", + closeCharacter: ")" | "]" | "}", +): number | undefined { + if (source[opening] !== openCharacter) return undefined; + let depth = 0; + for (let index = opening; index < source.length; index += 1) { + if (source[index] === openCharacter) depth += 1; + if (source[index] !== closeCharacter) continue; + depth -= 1; + if (depth === 0) return index; + } + return undefined; +} + +function matchingBrace(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "{", "}"); +} + +function matchingParenthesis(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "(", ")"); +} + +interface SwiftStructuralIndex { + braceDepth: Int32Array; + conditionalRanges: Range[]; +} + +function buildSwiftStructuralIndex(source: string): SwiftStructuralIndex { + const braceDepth = new Int32Array(source.length + 1); + for (let position = 0; position < source.length; position += 1) { + braceDepth[position + 1] = + braceDepth[position]! + (source[position] === "{" ? 1 : source[position] === "}" ? -1 : 0); + } + + const conditionalRanges: Range[] = []; + const directive = /^[\t ]*#(if|elseif|else|endif)\b/gm; + let depth = 0; + let rangeStart: number | undefined; + let match: RegExpExecArray | null; + while ((match = directive.exec(source)) !== null) { + if (match[1] === "if") { + if (depth === 0) rangeStart = match.index; + depth += 1; + } + if (match[1] === "endif" && depth > 0) { + depth -= 1; + if (depth === 0 && rangeStart != null) { + conditionalRanges.push({ start: rangeStart, end: match.index }); + rangeStart = undefined; + } + } + } + if (rangeStart != null) conditionalRanges.push({ start: rangeStart, end: source.length }); + return { braceDepth, conditionalRanges }; +} + +function braceDepthAt(index: SwiftStructuralIndex, openingBrace: number, position: number): number { + return index.braceDepth[position]! - index.braceDepth[openingBrace]!; +} + +function isInsideConditionalCompilation(index: SwiftStructuralIndex, position: number): boolean { + let low = 0; + let high = index.conditionalRanges.length - 1; + while (low <= high) { + const middle = (low + high) >>> 1; + const range = index.conditionalRanges[middle]!; + if (position < range.start) high = middle - 1; + else if (position >= range.end) low = middle + 1; + else return true; + } + return false; +} + +function lineStart(source: string, position: number): number { + const newline = source.lastIndexOf("\n", Math.max(0, position - 1)); + return newline === -1 ? 0 : newline + 1; +} + +function lineEnd(source: string, position: number): number { + const newline = source.indexOf("\n", position); + if (newline === -1) return source.length; + return source[newline - 1] === "\r" ? newline - 1 : newline; +} + +function lineIndent(source: string, position: number): string { + const start = lineStart(source, position); + return /^[\t ]*/.exec(source.slice(start, position))?.[0] ?? ""; +} + +function indentationUnit(parentIndent: string, childIndent: string): string { + if (childIndent.startsWith(parentIndent) && childIndent.length > parentIndent.length) { + return childIndent.slice(parentIndent.length); + } + if (childIndent.includes("\t")) return "\t"; + return " "; +} + +function newlineStyle(source: string): "\n" | "\r\n" | undefined { + if (/\r(?!\n)/.test(source)) return undefined; + const hasCRLF = source.includes("\r\n"); + const hasBareLF = /(^|[^\r])\n/.test(source); + if (hasCRLF && hasBareLF) return undefined; + return hasCRLF ? "\r\n" : "\n"; +} + +function decodeUTF8(bytes: Uint8Array): string | undefined { + try { + // ignoreBOM retains a leading U+FEFF so re-encoding preserves exact bytes. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return undefined; + } +} + +async function sourceSnapshot( + root: string, + relativePath: string, +): Promise { + const absolutePath = resolve(root, relativePath); + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) return undefined; + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SWIFT_FILE_BYTES) { + return undefined; + } + const bytes = new Uint8Array(await readFile(absolutePath)); + const source = decodeUTF8(bytes); + if (source == null || source.includes("\0")) return undefined; + return { + absolutePath, + relativePath, + bytes, + source, + hash: sha256(bytes), + mode: info.mode & 0o7777, + }; + } catch { + return undefined; + } +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [markerPath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, markerPath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +function plainClerkKitImports(sanitized: string, index: SwiftStructuralIndex): number[] { + const imports: number[] = []; + const pattern = /^[\t ]*import[\t ]+ClerkKit[\t ]*\r?$/gm; + let match: RegExpExecArray | null; + while ((match = pattern.exec(sanitized)) !== null) { + if (!isInsideConditionalCompilation(index, match.index)) imports.push(match.index); + } + return imports; +} + +interface SwiftImportLine { + start: number; + end: number; + moduleName: string; + moduleEnd: number; +} + +const IMPORT_DECLARATION_KINDS = new Set([ + "typealias", + "struct", + "class", + "enum", + "protocol", + "actor", + "let", + "var", + "func", + "macro", +]); + +function isHorizontalWhitespace(character: string | undefined): boolean { + return character === " " || character === "\t"; +} + +function skipHorizontalWhitespace(source: string, start: number, end: number): number { + let cursor = start; + while (cursor < end && isHorizontalWhitespace(source[cursor])) cursor += 1; + return cursor; +} + +function swiftIdentifierEnd(source: string, start: number, end: number): number | undefined { + if (!/[A-Za-z_]/.test(source[start] ?? "")) return undefined; + let cursor = start + 1; + while (cursor < end && /[A-Za-z0-9_]/.test(source[cursor] ?? "")) cursor += 1; + return cursor; +} + +/** + * Parses one sanitized Swift import line without a repeated, variable-width + * attribute regex. Attribute arguments are balanced structurally so even a + * hostile source line remains linear-time input. + */ +function parseSwiftImportLine( + source: string, + start: number, + end: number, +): SwiftImportLine | undefined { + let cursor = skipHorizontalWhitespace(source, start, end); + while (source[cursor] === "@") { + cursor += 1; + const firstComponentEnd = swiftIdentifierEnd(source, cursor, end); + if (firstComponentEnd == null) return undefined; + cursor = firstComponentEnd; + while (source[cursor] === ".") { + const componentEnd = swiftIdentifierEnd(source, cursor + 1, end); + if (componentEnd == null) return undefined; + cursor = componentEnd; + } + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null || closing >= end) return undefined; + cursor = closing + 1; + } + const whitespaceEnd = skipHorizontalWhitespace(source, cursor, end); + if (whitespaceEnd === cursor) return undefined; + cursor = whitespaceEnd; + } + + if (source.slice(cursor, cursor + 6) !== "import") return undefined; + cursor += 6; + const importWhitespaceEnd = skipHorizontalWhitespace(source, cursor, end); + if (importWhitespaceEnd === cursor) return undefined; + cursor = importWhitespaceEnd; + + let moduleEnd = swiftIdentifierEnd(source, cursor, end); + if (moduleEnd == null) return undefined; + let moduleName = source.slice(cursor, moduleEnd); + if (IMPORT_DECLARATION_KINDS.has(moduleName)) { + const kindWhitespaceEnd = skipHorizontalWhitespace(source, moduleEnd, end); + if (kindWhitespaceEnd === moduleEnd) return undefined; + cursor = kindWhitespaceEnd; + moduleEnd = swiftIdentifierEnd(source, cursor, end); + if (moduleEnd == null) return undefined; + moduleName = source.slice(cursor, moduleEnd); + } + + return { start, end, moduleName, moduleEnd }; +} + +function swiftImportLines(sanitized: string): SwiftImportLine[] { + const imports: SwiftImportLine[] = []; + let start = 0; + while (start <= sanitized.length) { + const newline = sanitized.indexOf("\n", start); + const end = + newline === -1 ? sanitized.length : sanitized[newline - 1] === "\r" ? newline - 1 : newline; + const importLine = parseSwiftImportLine(sanitized, start, end); + if (importLine) imports.push(importLine); + if (newline === -1) break; + start = newline + 1; + } + return imports; +} + +function anyClerkKitImports(sanitized: string): number[] { + return swiftImportLines(sanitized) + .filter( + (line) => + line.moduleName === "ClerkKit" && + (sanitized[line.moduleEnd] === "." || + skipHorizontalWhitespace(sanitized, line.moduleEnd, line.end) === line.end), + ) + .map((line) => line.start); +} + +function importInsertionPosition( + sanitized: string, + index: SwiftStructuralIndex, +): number | undefined { + const last = swiftImportLines(sanitized) + .filter((line) => !isInsideConditionalCompilation(index, line.start)) + .at(-1); + return last?.end; +} + +function appTypeRange( + sanitized: string, + index: SwiftStructuralIndex, +): AppStructure["appType"] | undefined { + const mainMatches = [...sanitized.matchAll(/@main\b/g)]; + if (mainMatches.length !== 1 || mainMatches[0]?.index == null) return undefined; + const mainIndex = mainMatches[0].index; + if (isInsideConditionalCompilation(index, mainIndex) || braceDepthAt(index, 0, mainIndex) !== 0) { + return undefined; + } + + let cursor = mainIndex + mainMatches[0][0].length; + while (true) { + cursor = skipWhitespace(sanitized, cursor); + const attribute = /^@[A-Za-z_][A-Za-z0-9_.]*/.exec(sanitized.slice(cursor)); + if (attribute) { + cursor += attribute[0].length; + cursor = skipWhitespace(sanitized, cursor); + if (sanitized[cursor] === "(") { + const closing = matchingParenthesis(sanitized, cursor); + if (closing == null) return undefined; + cursor = closing + 1; + } + continue; + } + const modifier = /^(?:public|internal|private|fileprivate|final|nonisolated)\b/.exec( + sanitized.slice(cursor), + ); + if (!modifier) break; + cursor += modifier[0].length; + } + + cursor = skipWhitespace(sanitized, cursor); + const declaration = /^struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(sanitized.slice(cursor)); + if (!declaration) return undefined; + const headerStart = cursor + declaration[0].length; + const openingBrace = sanitized.indexOf("{", headerStart); + if (openingBrace === -1) return undefined; + const header = sanitized.slice(headerStart, openingBrace); + if (/[;{}<>]/.test(header) || /\bwhere\b/.test(header)) return undefined; + const inheritance = /^\s*:\s*([A-Za-z0-9_.,\s]+)\s*$/.exec(header)?.[1]; + if (!inheritance || !inheritance.split(",").some((item) => item.trim() === "App")) { + return undefined; + } + const closingBrace = matchingBrace(sanitized, openingBrace); + if (closingBrace == null) return undefined; + if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(sanitized.slice(openingBrace, closingBrace))) { + return undefined; + } + return { + start: mainIndex, + end: closingBrace + 1, + declarationStart: cursor, + openingBrace, + closingBrace, + }; +} + +interface InitializerCandidate { + start: number; + end: number; + openingBrace: number; + closingBrace: number; + supported: boolean; +} + +function initializerCandidates( + sanitized: string, + appType: AppStructure["appType"], + index: SwiftStructuralIndex, +): InitializerCandidate[] { + const candidates: InitializerCandidate[] = []; + const pattern = /\binit\s*([?!])?\s*\(/g; + pattern.lastIndex = appType.openingBrace + 1; + let match: RegExpExecArray | null; + while ((match = pattern.exec(sanitized)) !== null && match.index < appType.closingBrace) { + if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; + const openingParenthesis = sanitized.indexOf("(", match.index); + const closingParenthesis = matchingParenthesis(sanitized, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= appType.closingBrace) { + candidates.push({ + start: match.index, + end: match.index + match[0].length, + openingBrace: -1, + closingBrace: -1, + supported: false, + }); + continue; + } + const openingBrace = sanitized.indexOf("{", closingParenthesis + 1); + const header = openingBrace === -1 ? "" : sanitized.slice(closingParenthesis + 1, openingBrace); + const closingBrace = openingBrace === -1 ? undefined : matchingBrace(sanitized, openingBrace); + const supported = + match[1] == null && + sanitized.slice(openingParenthesis + 1, closingParenthesis).trim() === "" && + openingBrace !== -1 && + openingBrace < appType.closingBrace && + header.trim() === "" && + closingBrace != null && + closingBrace <= appType.closingBrace; + candidates.push({ + start: match.index, + end: (closingBrace ?? closingParenthesis) + 1, + openingBrace, + closingBrace: closingBrace ?? -1, + supported, + }); + if (closingBrace != null) pattern.lastIndex = closingBrace + 1; + } + return candidates; +} + +function bodyRange( + sanitized: string, + appType: AppStructure["appType"], + index: SwiftStructuralIndex, +): AppStructure["body"] | undefined { + const candidates: AppStructure["body"][] = []; + const pattern = /\bvar\s+body\s*:\s*some\s+Scene\b/g; + pattern.lastIndex = appType.openingBrace + 1; + let match: RegExpExecArray | null; + while ((match = pattern.exec(sanitized)) !== null && match.index < appType.closingBrace) { + if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; + const openingBrace = skipWhitespace(sanitized, match.index + match[0].length); + if (sanitized[openingBrace] !== "{") continue; + const closingBrace = matchingBrace(sanitized, openingBrace); + if (closingBrace == null || closingBrace > appType.closingBrace) continue; + const declarationLineStart = lineStart(sanitized, match.index); + if (sanitized.slice(declarationLineStart, match.index).trim() !== "") continue; + candidates.push({ + start: match.index, + end: closingBrace + 1, + declarationStart: declarationLineStart, + openingBrace, + closingBrace, + }); + pattern.lastIndex = closingBrace + 1; + } + return candidates.length === 1 ? candidates[0] : undefined; +} + +interface RootExpression { + start: number; + end: number; + containerStart: number; + modifierStarts: number[]; +} + +function identifierEnd(source: string, start: number): number | undefined { + const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(start)); + return match ? start + match[0].length : undefined; +} + +function consumeBalancedSuffix(source: string, cursor: number, limit: number): number | undefined { + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null || closing >= limit) return undefined; + cursor = closing + 1; + cursor = skipWhitespace(source, cursor, limit); + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + cursor = closureEnd + 1; + } + return cursor; + } + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + return closureEnd + 1; + } + return undefined; +} + +function rootExpression( + sanitized: string, + start: number, + end: number, + containerStart: number, +): RootExpression | undefined { + let cursor = skipWhitespace(sanitized, start, end); + const expressionStart = cursor; + let identifier = identifierEnd(sanitized, cursor); + if (identifier == null) return undefined; + cursor = identifier; + while (true) { + const beforeDot = skipWhitespace(sanitized, cursor, end); + if (sanitized[beforeDot] !== ".") break; + const memberStart = skipWhitespace(sanitized, beforeDot + 1, end); + identifier = identifierEnd(sanitized, memberStart); + if (identifier == null) return undefined; + const afterMember = skipWhitespace(sanitized, identifier, end); + if (sanitized[afterMember] === "(" || sanitized[afterMember] === "{") break; + cursor = identifier; + } + cursor = skipWhitespace(sanitized, cursor, end); + const primaryEnd = consumeBalancedSuffix(sanitized, cursor, end); + if (primaryEnd == null) return undefined; + cursor = primaryEnd; + + const modifierStarts: number[] = []; + while (true) { + cursor = skipWhitespace(sanitized, cursor, end); + if (sanitized[cursor] !== ".") break; + const modifierStart = cursor; + const nameStart = skipWhitespace(sanitized, cursor + 1, end); + const nameEnd = identifierEnd(sanitized, nameStart); + if (nameEnd == null) return undefined; + cursor = skipWhitespace(sanitized, nameEnd, end); + const suffixEnd = consumeBalancedSuffix(sanitized, cursor, end); + if (suffixEnd == null) return undefined; + modifierStarts.push(modifierStart); + cursor = suffixEnd; + } + cursor = skipWhitespace(sanitized, cursor, end); + if (cursor !== end) return undefined; + return { + start: expressionStart, + end: trimWhitespaceEnd(sanitized, expressionStart, end), + containerStart, + modifierStarts, + }; +} + +function windowGroupRoot( + sanitized: string, + body: AppStructure["body"], +): RootExpression | undefined { + let cursor = skipWhitespace(sanitized, body.openingBrace + 1, body.closingBrace); + const windowGroupStart = cursor; + if (!sanitized.slice(cursor).startsWith("WindowGroup")) return undefined; + const wordEnd = cursor + "WindowGroup".length; + if (/[A-Za-z0-9_]/.test(sanitized[wordEnd] ?? "")) return undefined; + cursor = skipWhitespace(sanitized, wordEnd, body.closingBrace); + if (sanitized[cursor] === "(") { + const closingParenthesis = matchingParenthesis(sanitized, cursor); + if (closingParenthesis == null || closingParenthesis >= body.closingBrace) return undefined; + cursor = skipWhitespace(sanitized, closingParenthesis + 1, body.closingBrace); + } + if (sanitized[cursor] !== "{") return undefined; + const groupClosingBrace = matchingBrace(sanitized, cursor); + if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; + if (skipWhitespace(sanitized, groupClosingBrace + 1, body.closingBrace) !== body.closingBrace) { + return undefined; + } + const expressionStart = skipWhitespace(sanitized, cursor + 1, groupClosingBrace); + const expressionEnd = trimWhitespaceEnd(sanitized, expressionStart, groupClosingBrace); + if (expressionStart === expressionEnd) return undefined; + return rootExpression(sanitized, expressionStart, expressionEnd, windowGroupStart); +} + +/** + * Proves the narrow SwiftUI starter root used by the optional AuthView + * scaffold. This deliberately shares the direct-config parser's structural + * ownership checks: the sole unconditional top-level `@main` declaration must + * be a SwiftUI `App`, and its one `body: some Scene` must own the WindowGroup + * whose direct root is ContentView. + */ +export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { + const sanitized = sanitizeSwiftSource(source); + const structuralIndex = buildSwiftStructuralIndex(sanitized); + const appType = appTypeRange(sanitized, structuralIndex); + if (!appType) return false; + const body = bodyRange(sanitized, appType, structuralIndex); + if (!body) return false; + const root = windowGroupRoot(sanitized, body); + if (!root) return false; + + const groupOpeningBrace = sanitized.lastIndexOf("{", root.start); + if (groupOpeningBrace < root.containerStart) return false; + const container = sanitized.slice(root.containerStart, groupOpeningBrace).replace(/\s+/g, ""); + if (container !== "WindowGroup") return false; + + const expression = sanitized.slice(root.start, root.end).replace(/\s+/g, ""); + return expression === "ContentView()" || expression === "ContentView().environment(Clerk.shared)"; +} + +function exactEnvironmentModifier( + sanitized: string, + root: RootExpression, +): { found: boolean; conflicting: boolean } { + let found = false; + let conflicting = false; + for (const modifierStart of root.modifierStarts) { + const remainder = sanitized.slice(modifierStart, root.end); + const name = /^\.\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(remainder)?.[1]; + if (name !== "environment") continue; + const openingParenthesis = sanitized.indexOf("(", modifierStart); + const closingParenthesis = matchingParenthesis(sanitized, openingParenthesis); + if (closingParenthesis == null || closingParenthesis > root.end) { + conflicting = true; + continue; + } + const argumentsSource = sanitized.slice(openingParenthesis + 1, closingParenthesis); + if (/^\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { + found = true; + } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { + conflicting = true; + } + } + return { found, conflicting }; +} + +function exactConfigureCall( + source: string, + sanitized: string, + initializer: AppStructure["initializer"] | undefined, + index: SwiftStructuralIndex, +): { key?: string; callIndex?: number; conflict: boolean } { + const calls = [...sanitized.matchAll(/\bClerk\s*\.\s*configure\s*\(/g)]; + if (calls.length === 0) return { conflict: false }; + if (calls.length !== 1 || !initializer || calls[0]?.index == null) return { conflict: true }; + const callIndex = calls[0].index; + if ( + callIndex <= initializer.openingBrace || + callIndex >= initializer.closingBrace || + braceDepthAt(index, initializer.openingBrace, callIndex) !== 1 + ) { + return { conflict: true }; + } + const openingParenthesis = sanitized.indexOf("(", callIndex); + const closingParenthesis = matchingParenthesis(sanitized, openingParenthesis); + if (closingParenthesis == null || closingParenthesis >= initializer.closingBrace) { + return { conflict: true }; + } + let before = callIndex - 1; + while (sanitized[before] === " " || sanitized[before] === "\t") before -= 1; + let after = closingParenthesis + 1; + while (sanitized[after] === " " || sanitized[after] === "\t") after += 1; + if ( + !["{", "}", ";", "\n", "\r"].includes(sanitized[before] ?? "") || + !["}", ";", "\n", "\r"].includes(sanitized[after] ?? "") + ) { + return { conflict: true }; + } + const originalArguments = source.slice(openingParenthesis + 1, closingParenthesis); + const literal = /^\s*publishableKey\s*:\s*"(pk_(?:test|live)_[A-Za-z0-9+/_=-]+)"\s*,?\s*$/.exec( + originalArguments, + )?.[1]; + return literal ? { key: literal, callIndex, conflict: false } : { conflict: true }; +} + +function validateInlineKey(value: string): "development" | "production" | undefined { + try { + return decodePublishableKey(value).instanceType; + } catch { + return undefined; + } +} + +function hasDirectStoredProperty( + sanitized: string, + appType: AppStructure["appType"], + body: AppStructure["body"], + index: SwiftStructuralIndex, +): boolean { + const pattern = /\b(?:let|var)\s+[A-Za-z_][A-Za-z0-9_]*/g; + pattern.lastIndex = appType.openingBrace + 1; + let match: RegExpExecArray | null; + while ((match = pattern.exec(sanitized)) !== null && match.index < appType.closingBrace) { + if (match.index === body.start) continue; + if (braceDepthAt(index, appType.openingBrace, match.index) === 1) return true; + } + return false; +} + +function bodyHasLeadingAttribute(source: string, body: AppStructure["body"]): boolean { + let cursor = body.declarationStart; + while (cursor > 0) { + const previousEnd = cursor - 1; + const previousStart = lineStart(source, previousEnd); + const line = source.slice(previousStart, previousEnd + 1).trim(); + if (line === "") { + cursor = previousStart; + continue; + } + return line.startsWith("@"); + } + return false; +} + +function hasPreinitializationClerkSharedAccess( + sanitized: string, + appType: AppStructure["appType"], + initializer: AppStructure["initializer"] | undefined, + configureCallIndex: number | undefined, + index: SwiftStructuralIndex, +): boolean { + for (const match of sanitized.matchAll(/\bClerk\s*\.\s*shared\b/g)) { + if (match.index == null) continue; + const position = match.index; + if (position < appType.start || position >= appType.end) { + if (braceDepthAt(index, 0, position) === 0) return true; + continue; + } + if (braceDepthAt(index, appType.openingBrace, position) === 1) return true; + if ( + initializer && + configureCallIndex != null && + position > initializer.openingBrace && + position < configureCallIndex + ) { + return true; + } + } + return false; +} + +function environmentInsertion( + source: string, + newline: "\n" | "\r\n", + root: RootExpression, +): AppStructure["environmentInsertion"] { + const trailingLine = source.slice(root.end, lineEnd(source, root.end)); + const sharesLineWithComment = /\/\*|\/\//.test(trailingLine); + const isCompact = !source.slice(root.start, root.end).includes("\n") && !sharesLineWithComment; + if (isCompact || sharesLineWithComment) { + return { index: root.end, textBeforeKey: ".environment(Clerk.shared)" }; + } + const rootIndent = lineIndent(source, root.start); + const lastModifier = root.modifierStarts.at(-1); + const modifierIndent = + lastModifier == null + ? `${rootIndent}${indentationUnit(lineIndent(source, root.containerStart), rootIndent)}` + : lineIndent(source, lastModifier); + return { + index: root.end, + textBeforeKey: `${newline}${modifierIndent}.environment(Clerk.shared)`, + }; +} + +function parseAppStructure( + source: string, +): { structure: AppStructure } | { blocker: IOSDirectConfigBlocker } { + const newline = newlineStyle(source); + if (!newline) { + return { + blocker: { + code: "unsupported-line-endings", + message: "The Swift entry source uses mixed or unsupported line endings.", + }, + }; + } + const sanitized = sanitizeSwiftSource(source); + const structuralIndex = buildSwiftStructuralIndex(sanitized); + const appType = appTypeRange(sanitized, structuralIndex); + if (!appType) { + return { + blocker: { + code: "unsupported-app-structure", + message: "The entry source is not one unconditional, safely editable @main SwiftUI App.", + }, + }; + } + const body = bodyRange(sanitized, appType, structuralIndex); + if (!body) { + return { + blocker: { + code: "unsupported-scene", + message: "The @main App does not contain exactly one safely editable body: some Scene.", + }, + }; + } + const root = windowGroupRoot(sanitized, body); + if (!root) { + return { + blocker: { + code: "unsupported-scene", + message: + "The App scene is not one WindowGroup with one safely editable root view expression.", + }, + }; + } + + const initializerMatches = initializerCandidates(sanitized, appType, structuralIndex); + if ( + initializerMatches.length > 1 || + initializerMatches.some((candidate) => !candidate.supported) + ) { + return { + blocker: { + code: "unsupported-initializer", + message: "The @main App initializer is ambiguous or cannot be edited safely.", + }, + }; + } + const initializerCandidate = initializerMatches[0]; + const initializer = initializerCandidate + ? { + start: initializerCandidate.start, + end: initializerCandidate.end, + openingBrace: initializerCandidate.openingBrace, + closingBrace: initializerCandidate.closingBrace, + } + : undefined; + if (hasDirectStoredProperty(sanitized, appType, body, structuralIndex)) { + return { + blocker: { + code: "unsupported-initializer", + message: + "The @main App has stored startup state whose initialization cannot be proven to occur after Clerk configuration.", + }, + }; + } + if (!initializer && bodyHasLeadingAttribute(source, body)) { + return { + blocker: { + code: "unsupported-initializer", + message: + "The @main App has attributed startup state but no explicit initializer; add Clerk configuration manually or add a simple init() first.", + }, + }; + } + + const configure = exactConfigureCall(source, sanitized, initializer, structuralIndex); + if (configure.conflict) { + return { + blocker: { + code: "conflicting-configuration", + message: + "An existing Clerk configuration call is not the exact supported inline initializer form.", + }, + }; + } + if (configure.key) { + const instanceType = validateInlineKey(configure.key); + if (!instanceType) { + return { + blocker: { + code: "invalid-inline-publishable-key", + message: "The existing inline Clerk publishable key is malformed and was preserved.", + }, + }; + } + if (instanceType === "production") { + return { + blocker: { + code: "production-inline-publishable-key", + message: + "The existing inline production publishable key was preserved for manual review.", + }, + }; + } + if ( + !initializer || + configure.callIndex == null || + sanitized.slice(initializer.openingBrace + 1, configure.callIndex).trim() !== "" + ) { + return { + blocker: { + code: "preinitialization-clerk-access", + message: + "The existing Clerk configuration is not the first executable statement in the @main App initializer.", + }, + }; + } + } + if ( + hasPreinitializationClerkSharedAccess( + sanitized, + appType, + initializer, + configure.callIndex, + structuralIndex, + ) + ) { + return { + blocker: { + code: "preinitialization-clerk-access", + message: "Clerk.shared is accessed before the proven App initializer configuration point.", + }, + }; + } + + const environment = exactEnvironmentModifier(sanitized, root); + if (environment.conflicting) { + return { + blocker: { + code: "conflicting-environment", + message: "The WindowGroup root contains a conflicting Clerk environment modifier.", + }, + }; + } + const importInsertion = importInsertionPosition(sanitized, structuralIndex); + if (importInsertion == null) { + return { + blocker: { + code: "unsupported-app-structure", + message: "The entry source has no unconditional top-level import section.", + }, + }; + } + const plainImports = plainClerkKitImports(sanitized, structuralIndex); + const allClerkImports = anyClerkKitImports(sanitized); + if ( + plainImports.length > 1 || + (allClerkImports.length > 0 && + (plainImports.length !== 1 || allClerkImports.length !== plainImports.length)) + ) { + return { + blocker: { + code: "unsupported-app-structure", + message: "The entry source contains conditional, scoped, or duplicate ClerkKit imports.", + }, + }; + } + + const appIndent = lineIndent(source, appType.declarationStart); + const bodyIndent = lineIndent(source, body.start); + const unit = indentationUnit(appIndent, bodyIndent); + let configurationInsertion: AppStructure["configurationInsertion"]; + if (configure.key) { + configurationInsertion = { kind: "existing-literal" }; + } else if (initializer) { + const bodyStart = initializer.openingBrace + 1; + const firstContent = skipWhitespace(sanitized, bodyStart, initializer.closingBrace); + configurationInsertion = { + kind: "existing-initializer", + index: bodyStart, + statementIndent: `${lineIndent(source, initializer.start)}${unit}`, + multiline: source.slice(bodyStart, firstContent).includes("\n"), + }; + } else { + configurationInsertion = { + kind: "new-initializer", + index: body.declarationStart, + memberIndent: bodyIndent, + statementIndent: `${bodyIndent}${unit}`, + }; + } + + return { + structure: { + source, + sanitized, + newline, + appType, + initializer, + body, + root, + hasClerkKitImport: plainImports.length === 1, + importInsertion, + existingPublishableKey: configure.key, + hasEnvironment: environment.found, + configurationInsertion, + environmentInsertion: environment.found + ? undefined + : environmentInsertion(source, newline, root), + }, + }; +} + +async function gitDirtyState( + root: string, + absolutePath: string, +): Promise<"clean" | "dirty" | "not-repository" | "unknown"> { + try { + const child = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", absolutePath], + { cwd: root, stdout: "pipe", stderr: "ignore" }, + ); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode === 0) return output.trim() === "" ? "clean" : "dirty"; + const probe = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { + cwd: root, + stdout: "ignore", + stderr: "ignore", + }); + return (await probe.exited) === 0 ? "unknown" : "not-repository"; + } catch { + return "unknown"; + } +} + +function semanticActions(sourcePath: string, changes: IOSDirectConfigChanges): string[] { + const actions: string[] = []; + if (changes.clerkKitImport === "insert") { + actions.push(`Add import ClerkKit to ${sourcePath}.`); + } + if (changes.configuration === "insert-initializer") { + actions.push( + `Add a simple @main App initializer in ${sourcePath} and configure Clerk with the selected development publishable key (redacted).`, + ); + } else if (changes.configuration === "insert-statement") { + actions.push( + `Configure Clerk first in the existing @main App initializer in ${sourcePath} with the selected development publishable key (redacted).`, + ); + } else { + actions.push( + `Verify the existing inline Clerk configuration in ${sourcePath} matches the selected development publishable key (redacted).`, + ); + } + if (changes.environment === "insert") { + actions.push(`Inject Clerk.shared into the WindowGroup root environment in ${sourcePath}.`); + } + return actions; +} + +async function prepareDirectConfig( + options: IOSDirectConfigPlanOptions, +): Promise { + const root = resolve(options.root); + const absoluteProjectPath = resolve(root, options.projectPath); + if ( + !options.targetId || + !options.projectPath || + resolve(root, relative(root, absoluteProjectPath)) !== absoluteProjectPath || + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) + ) { + return blocked( + options, + root, + options.projectPath, + "invalid-selection", + "The selected Xcode project or target is invalid.", + ); + } + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const inspection = await inspectIOSProject(root, { target: options.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target could not be proven.", + ); + } + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (generator != null) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated Swift sources.`, + ); + } + const target = inspection.appTargets.find( + (candidate) => candidate.id === options.targetId && candidate.projectPath === projectPath, + ); + if (!target) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target disappeared during inspection.", + ); + } + if (!target.swift.evidenceComplete) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "The selected target's complete shipping Swift source membership could not be proven.", + ); + } + if (target.swift.entryPoints.length !== 1 || !target.swift.entryPoints[0]?.path) { + return blocked( + options, + root, + projectPath, + "ambiguous-entry-point", + "The selected target must contain exactly one shipping @main Swift entry point.", + ); + } + const sourcePath = target.swift.entryPoints[0].path; + const snapshot = await sourceSnapshot(root, sourcePath); + if (!snapshot) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "The selected @main Swift source is not a safe, readable in-root regular file.", + { plan: makePlan(options, root, projectPath, "blocked", { sourcePath }) }, + ); + } + const sourcePlan = makePlan(options, root, projectPath, "ready", { + sourcePath, + expectedSourceHash: snapshot.hash, + }); + + const parsed = parseAppStructure(snapshot.source); + if ("blocker" in parsed) { + return blocked(options, root, projectPath, parsed.blocker.code, parsed.blocker.message, { + plan: sourcePlan, + snapshot, + }); + } + const structure = parsed.structure; + + const configureElsewhere = target.swift.configureCalls.some((call) => call.path !== sourcePath); + if ( + configureElsewhere || + target.swift.configureCalls.length > (structure.existingPublishableKey ? 1 : 0) + ) { + return blocked( + options, + root, + projectPath, + "conflicting-configuration", + "Another target-owned Clerk configuration call exists outside the exact supported initializer binding.", + { plan: sourcePlan, snapshot, structure }, + ); + } + const environmentElsewhere = target.swift.environmentInjections.some( + (evidence) => evidence.path !== sourcePath, + ); + if ( + environmentElsewhere || + (target.swift.environmentInjections.length > 0 && !structure.hasEnvironment) + ) { + return blocked( + options, + root, + projectPath, + "conflicting-environment", + "Another Clerk environment injection exists outside the exact WindowGroup root binding.", + { plan: sourcePlan, snapshot, structure }, + ); + } + + const changes: IOSDirectConfigChanges = { + clerkKitImport: structure.hasClerkKitImport ? "satisfied" : "insert", + configuration: + structure.configurationInsertion.kind === "new-initializer" + ? "insert-initializer" + : structure.configurationInsertion.kind === "existing-initializer" + ? "insert-statement" + : "verify-existing", + environment: structure.hasEnvironment ? "satisfied" : "insert", + }; + const changesSource = + changes.clerkKitImport === "insert" || + changes.configuration !== "verify-existing" || + changes.environment === "insert"; + if (changesSource && !options.allowDirty) { + const dirty = await gitDirtyState(root, snapshot.absolutePath); + if (dirty === "dirty") { + return blocked( + options, + root, + projectPath, + "dirty-source", + `The planned Swift source ${sourcePath} has existing Git changes; pass the explicit dirty-file override to include it.`, + { plan: sourcePlan, snapshot, structure }, + ); + } + if (dirty === "unknown") { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + `Git state for the planned Swift source ${sourcePath} could not be verified.`, + { plan: sourcePlan, snapshot, structure }, + ); + } + } + return { + snapshot, + structure, + plan: makePlan(options, root, projectPath, "ready", { + sourcePath, + expectedSourceHash: snapshot.hash, + changes, + actions: semanticActions(sourcePath, changes), + }), + }; +} + +export async function planIOSDirectConfig( + options: IOSDirectConfigPlanOptions, +): Promise { + return (await prepareDirectConfig(options)).plan; +} + +function validatedDevelopmentKey(value: string): string | undefined { + if (value.trim() !== value || !/^pk_test_[A-Za-z0-9+/_=-]+$/.test(value)) return undefined; + try { + return decodePublishableKey(value).instanceType === "development" ? value : undefined; + } catch { + return undefined; + } +} + +function applyEdits(source: string, edits: SourceEdit[]): string { + let candidate = source; + for (const edit of [...edits].sort((a, b) => b.index - a.index)) { + candidate = `${candidate.slice(0, edit.index)}${edit.text}${candidate.slice(edit.index)}`; + } + return candidate; +} + +function directConfigCandidate(structure: AppStructure, publishableKey: string): string { + const edits: SourceEdit[] = []; + if (!structure.hasClerkKitImport) { + edits.push({ + index: structure.importInsertion, + text: `${structure.newline}import ClerkKit`, + }); + } + const statement = `Clerk.configure(publishableKey: "${publishableKey}")`; + if (structure.configurationInsertion.kind === "new-initializer") { + const insertion = structure.configurationInsertion; + edits.push({ + index: insertion.index, + text: `${insertion.memberIndent}init() {${structure.newline}${insertion.statementIndent}${statement}${structure.newline}${insertion.memberIndent}}${structure.newline}${structure.newline}`, + }); + } else if (structure.configurationInsertion.kind === "existing-initializer") { + const insertion = structure.configurationInsertion; + edits.push({ + index: insertion.index, + text: insertion.multiline + ? `${structure.newline}${insertion.statementIndent}${statement}` + : ` ${statement};`, + }); + } + if (!structure.hasEnvironment && structure.environmentInsertion) { + edits.push({ + index: structure.environmentInsertion.index, + text: structure.environmentInsertion.textBeforeKey, + }); + } + return applyEdits(structure.source, edits); +} + +function redactedKeyBlocker( + plan: IOSDirectConfigPlan, + code: IOSDirectConfigBlockerCode, + message: string, +): IOSDirectConfigPlan { + return { ...plan, status: "blocked", actions: [], blockers: [{ code, message }] }; +} + +function mutationWithHiddenBytes( + snapshot: FileSnapshot, + candidateBytes: Uint8Array, +): IOSDirectConfigFileMutation { + const mutation = { + absolutePath: snapshot.absolutePath, + expectedHash: snapshot.hash, + candidateHash: sha256(candidateBytes), + mode: snapshot.mode, + } as IOSDirectConfigFileMutation; + Object.defineProperties(mutation, { + originalBytes: { value: snapshot.bytes, enumerable: false }, + candidateBytes: { value: candidateBytes, enumerable: false }, + }); + return mutation; +} + +function readyPreparedMutation( + plan: IOSDirectConfigPlan, + mutation: IOSDirectConfigFileMutation, + validator: () => Promise, +): IOSDirectConfigPreparedMutation { + const prepared = { status: "ready", plan } as IOSDirectConfigPreparedMutation; + Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + preparedValidators.set(prepared, validator); + return prepared; +} + +async function exactPostcondition( + plan: IOSDirectConfigPlan, + publishableKey: string, + candidateHash: string, +): Promise { + const prepared = await prepareDirectConfig({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return ( + prepared.plan.status === "ready" && + prepared.snapshot?.hash === candidateHash && + prepared.structure?.existingPublishableKey === publishableKey && + prepared.structure.hasClerkKitImport && + prepared.structure.hasEnvironment && + prepared.plan.changes?.configuration === "verify-existing" && + prepared.plan.changes.clerkKitImport === "satisfied" && + prepared.plan.changes.environment === "satisfied" + ); +} + +/** + * @internal Prepare one key-bearing Swift mutation without writing it. The + * returned plan/result remains redacted; only the non-enumerable mutation + * bytes are sensitive to accidental output. + */ +export async function prepareIOSDirectConfigMutation( + plan: IOSDirectConfigPlan, + publishableKey: string, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-direct-config" || + !plan.sourcePath || + !plan.expectedSourceHash || + !plan.changes + ) { + return { + status: "blocked", + plan: redactedKeyBlocker( + plan, + "invalid-selection", + "The direct iOS configuration plan is incomplete or unsupported.", + ), + }; + } + const normalizedKey = validatedDevelopmentKey(publishableKey); + if (!normalizedKey) { + let production = false; + try { + production = decodePublishableKey(publishableKey).instanceType === "production"; + } catch { + // The redacted blocker below covers malformed values. + } + return { + status: "blocked", + plan: redactedKeyBlocker( + plan, + production ? "production-publishable-key" : "invalid-publishable-key", + production + ? "Automatic direct iOS configuration accepts a development publishable key only." + : "A valid Clerk development publishable key is required.", + ), + }; + } + + const current = await prepareDirectConfig({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: plan.allowDirty, + }); + if ( + current.plan.status === "blocked" || + !current.snapshot || + !current.structure || + !current.plan.expectedSourceHash + ) { + return { status: "blocked", plan: current.plan }; + } + if ( + current.plan.sourcePath !== plan.sourcePath || + current.plan.expectedSourceHash !== plan.expectedSourceHash + ) { + return { + status: "stale", + plan, + message: "The selected Swift entry source changed after the plan was created.", + }; + } + if ( + current.structure.existingPublishableKey && + current.structure.existingPublishableKey !== normalizedKey + ) { + return { + status: "blocked", + plan: redactedKeyBlocker( + plan, + "different-inline-publishable-key", + "The existing inline development publishable key belongs to a different Clerk application and was preserved.", + ), + }; + } + + const candidate = directConfigCandidate(current.structure, normalizedKey); + const candidateBytes = new TextEncoder().encode(candidate); + const candidateHash = sha256(candidateBytes); + if (candidateHash === current.snapshot.hash) { + return { status: "satisfied", plan }; + } + const mutation = mutationWithHiddenBytes(current.snapshot, candidateBytes); + return readyPreparedMutation(plan, mutation, async () => + exactPostcondition(plan, normalizedKey, candidateHash), + ); +} + +/** @internal Validate a committed prepared mutation with the same exact structural parser. */ +export async function validatePreparedIOSDirectConfig( + prepared: IOSDirectConfigPreparedMutation, +): Promise { + return (await preparedValidators.get(prepared)?.()) ?? false; +} + +async function fileHash(path: string): Promise { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SWIFT_FILE_BYTES) { + return undefined; + } + return sha256(await readFile(path)); + } catch { + return undefined; + } +} + +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 where directory fsync is unavailable. + } +} + +async function cleanupTemporarySource(path: string): Promise { + try { + await rm(path, { force: true }); + } catch { + throw new Error( + "A temporary direct iOS source file could not be removed. Inspect the entry-source directory for a .clerk-*.tmp file before retrying.", + ); + } +} + +async function stageSource(mutation: IOSDirectConfigFileMutation): Promise { + const temporaryPath = resolve( + dirname(mutation.absolutePath), + `.${basename(mutation.absolutePath)}.clerk-${process.pid}-${randomUUID()}.tmp`, + ); + let created = false; + try { + const file = await open(temporaryPath, "wx", 0o600); + created = true; + try { + await file.writeFile(mutation.candidateBytes); + await file.sync(); + } finally { + await file.close(); + } + await chmod(temporaryPath, mutation.mode); + return { temporaryPath, mutation, committed: false }; + } catch { + if (created) await cleanupTemporarySource(temporaryPath); + throw new Error("The direct iOS source update could not be staged safely."); + } +} + +async function commitStagedSource(staged: StagedSource): Promise<"written" | "stale"> { + if ((await fileHash(staged.mutation.absolutePath)) !== staged.mutation.expectedHash) { + return "stale"; + } + await rename(staged.temporaryPath, staged.mutation.absolutePath); + staged.committed = true; + await syncDirectory(dirname(staged.mutation.absolutePath)); + return "written"; +} + +async function rollbackStagedSource(staged: StagedSource): Promise { + if (!staged.committed) return true; + if ((await fileHash(staged.mutation.absolutePath)) !== staged.mutation.candidateHash) { + return false; + } + const rollbackMutation = mutationWithHiddenBytes( + { + absolutePath: staged.mutation.absolutePath, + relativePath: "", + bytes: staged.mutation.candidateBytes, + source: "", + hash: staged.mutation.candidateHash, + mode: staged.mutation.mode, + }, + staged.mutation.originalBytes, + ); + const rollback = await stageSource(rollbackMutation); + try { + if ((await commitStagedSource(rollback)) !== "written") return false; + staged.committed = false; + return (await fileHash(staged.mutation.absolutePath)) === staged.mutation.expectedHash; + } finally { + await cleanupTemporarySource(rollback.temporaryPath); + } +} + +export async function applyIOSDirectConfig( + plan: IOSDirectConfigPlan, + publishableKey: string, + options: IOSDirectConfigApplyOptions = {}, +): Promise { + const prepared = await prepareIOSDirectConfigMutation(plan, publishableKey); + if (prepared.status !== "ready") return prepared; + + const staged = await stageSource(prepared.mutation); + try { + await options.beforeCommit?.(); + if ((await commitStagedSource(staged)) === "stale") { + return { + status: "stale", + plan, + message: "The selected Swift entry source changed while the update was being committed.", + }; + } + await options.beforePostWriteValidation?.(); + const valid = + options.forcePostWriteValidationFailure !== true && + (await validatePreparedIOSDirectConfig(prepared)); + if (valid) return { status: "applied", plan }; + + if (!(await rollbackStagedSource(staged))) { + throw new Error( + "The direct iOS source update failed validation, and a concurrent edit prevented safe rollback. Inspect the entry source before retrying.", + ); + } + return { + status: "rolled-back", + plan, + message: "The direct iOS source update failed validation and the original file was restored.", + }; + } catch (error) { + if (staged.committed && !(await rollbackStagedSource(staged))) { + throw new Error( + "The direct iOS source update failed, and a concurrent edit prevented safe rollback. Inspect the entry source before retrying.", + ); + } + if (error instanceof Error && error.message.includes("concurrent edit")) throw error; + return { + status: "rolled-back", + plan, + message: "The direct iOS source update failed and the original file was restored.", + }; + } finally { + await cleanupTemporarySource(staged.temporaryPath); + } +} diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts new file mode 100644 index 000000000..3e9e01cfd --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -0,0 +1,488 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { + appendFile, + chmod, + link, + mkdir, + mkdtemp, + readFile, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyIOSExistingFileTransaction, + hashIOSFileBytes, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; +import { + planIOSMissingEntitlementsSettings, + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, +} from "./entitlements-settings.ts"; +import { + planIOSSDKInstall, + prepareIOSSDKInstallMutation, + validateIOSSDKInstallPostcondition, +} from "./install-sdk.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const SYNCHRONIZED_ROOT_ID = "515151515151515151515151"; +const ANCESTOR_SYNCHRONIZED_ROOT_ID = "525252525252525252525252"; +const DEVICE_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"; +const SIMULATOR_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"; +const MAC_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"; +const temporaryDirectories: string[] = []; + +interface MutableProject { + project: ReturnType; + objects: PbxObjects; +} + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-entitlements-settings-")); + temporaryDirectories.push(root); + return root; +} + +function pbxprojPath(root: string): string { + return join(root, "MyApp.xcodeproj", "project.pbxproj"); +} + +function entitlementsPath(root: string): string { + return join(root, "MyApp", "MyApp.entitlements"); +} + +function mutableProject(source: string): MutableProject { + const project = parsePbxProject(source); + const archive = project as unknown as { objects: PbxObjects }; + return { project, objects: archive.objects }; +} + +function settings(objects: PbxObjects, id: string): Record { + return objects[id]!.buildSettings as Record; +} + +async function makeSynchronizedFixture( + options: { + secondTarget?: boolean; + clerkSDK?: boolean; + shareRoot?: boolean; + retainClassicReference?: boolean; + } = {}, +): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, { + secondTarget: options.secondTarget, + clerkSDK: options.clerkSDK, + }); + const path = pbxprojPath(root); + const graph = mutableProject(await readFile(path, "utf8")); + const mainGroup = graph.objects[IOS_FIXTURE_IDS.mainGroup]!; + const children = mainGroup.children as string[]; + mainGroup.children = [ + ...children.filter((id) => id !== IOS_FIXTURE_IDS.entitlementsFile), + SYNCHRONIZED_ROOT_ID, + ]; + graph.objects[SYNCHRONIZED_ROOT_ID] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "MyApp", + sourceTree: "", + }; + graph.objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [SYNCHRONIZED_ROOT_ID]; + if (options.shareRoot) { + graph.objects[IOS_FIXTURE_IDS.secondTarget]!.fileSystemSynchronizedGroups = [ + SYNCHRONIZED_ROOT_ID, + ]; + } + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const values = settings(graph.objects, id); + delete values.CODE_SIGN_ENTITLEMENTS; + values[MAC_SETTING] = "MyApp/MyApp.mac.entitlements"; + } + if (!options.retainClassicReference) { + delete graph.objects[IOS_FIXTURE_IDS.entitlementsFile]; + } + await writeFile(path, buildPbxProject(graph.project)); + await rm(entitlementsPath(root)); + return root; +} + +function options(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }; +} + +function blockerCodes( + plan: Awaited>, +): string[] { + return plan.blockers.map((item) => item.code); +} + +async function createCrossProjectClassicReference(root: string): Promise { + const projectPath = join(root, "Other.xcodeproj"); + await mkdir(projectPath); + await writeFile( + join(projectPath, "project.pbxproj"), + `// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { }; + objectVersion = 56; + objects = { + 616161616161616161616161 = { + isa = PBXProject; + mainGroup = 626262626262626262626262; + projectDirPath = ""; + projectRoot = ""; + targets = ( ); + }; + 626262626262626262626262 = { + isa = PBXGroup; + children = ( 636363636363636363636363, ); + sourceTree = ""; + }; + 636363636363636363636363 = { + isa = PBXFileReference; + lastKnownFileType = text.plist.entitlements; + path = MyApp/MyApp.entitlements; + sourceTree = ""; + }; + }; + rootObject = 616161616161616161616161; +} +`, + ); +} + +async function initializeGitRepository(root: string): Promise { + const child = Bun.spawn(["git", "init", "--quiet", root], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).arrayBuffer(); + const stderr = new Response(child.stderr).arrayBuffer(); + const [exitCode] = await Promise.all([child.exited, stdout, stderr]); + if (exitCode !== 0) throw new Error("Could not initialize the test Git repository."); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("missing iOS entitlements build settings", () => { + test("adds SDK-qualified settings to every selected configuration and is byte-idempotent", async () => { + const root = await makeSynchronizedFixture({ secondTarget: true }); + await chmod(pbxprojPath(root), 0o640); + const before = await readFile(pbxprojPath(root)); + const beforeGraph = mutableProject(before.toString()); + const secondBefore = JSON.stringify({ + debug: beforeGraph.objects[IOS_FIXTURE_IDS.secondDebug], + release: beforeGraph.objects[IOS_FIXTURE_IDS.secondRelease], + }); + + const plan = await planIOSMissingEntitlementsSettings(options(root)); + expect(plan).toMatchObject({ + status: "ready", + entitlementsPath: "MyApp/MyApp.entitlements", + buildSettingPath: "MyApp/MyApp.entitlements", + synchronizedRootPath: "MyApp", + configurationIds: [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease], + blockers: [], + }); + const prepared = await prepareIOSMissingEntitlementsSettingsMutation(plan); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + if (prepared.status !== "ready") throw new Error("Expected a prepared mutation."); + expect(prepared.mutation.originalBytes).toEqual(before); + + const result = await applyIOSExistingFileTransaction( + [prepared.mutation], + [() => validateIOSMissingEntitlementsSettingsPostcondition(plan)], + ); + expect(result.status).toBe("applied"); + expect((await stat(pbxprojPath(root))).mode & 0o777).toBe(0o640); + const after = await readFile(pbxprojPath(root)); + const afterGraph = mutableProject(after.toString()); + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + expect(settings(afterGraph.objects, id)).toMatchObject({ + [DEVICE_SETTING]: "MyApp/MyApp.entitlements", + [SIMULATOR_SETTING]: "MyApp/MyApp.entitlements", + [MAC_SETTING]: "MyApp/MyApp.mac.entitlements", + }); + } + expect( + JSON.stringify({ + debug: afterGraph.objects[IOS_FIXTURE_IDS.secondDebug], + release: afterGraph.objects[IOS_FIXTURE_IDS.secondRelease], + }), + ).toBe(secondBefore); + expect(await Bun.file(entitlementsPath(root)).exists()).toBe(false); + + const rerun = await planIOSMissingEntitlementsSettings(options(root)); + expect(rerun.status).toBe("satisfied"); + expect((await prepareIOSMissingEntitlementsSettingsMutation(rerun)).status).toBe("satisfied"); + expect(await readFile(pbxprojPath(root))).toEqual(after); + }); + + test("composes with an SDK candidate into one project mutation", async () => { + const root = await makeSynchronizedFixture({ clerkSDK: false }); + const entitlementsPlan = await planIOSMissingEntitlementsSettings(options(root)); + const sdkPlan = await planIOSSDKInstall(options(root)); + const sdk = await prepareIOSSDKInstallMutation(sdkPlan); + expect(sdk.status).toBe("ready"); + if (sdk.status !== "ready") throw new Error("Expected an SDK mutation."); + + const combined = await prepareIOSMissingEntitlementsSettingsMutation( + entitlementsPlan, + sdk.mutation, + ); + expect(combined.status).toBe("ready"); + if (combined.status !== "ready") throw new Error("Expected a combined mutation."); + expect(combined.mutation.path).toBe(sdk.mutation.path); + expect(combined.mutation.originalHash).toBe(sdk.mutation.originalHash); + expect(combined.mutation.candidateHash).not.toBe(sdk.mutation.candidateHash); + + const result = await applyIOSExistingFileTransaction( + [combined.mutation], + [ + () => validateIOSSDKInstallPostcondition(sdk.plan), + () => validateIOSMissingEntitlementsSettingsPostcondition(entitlementsPlan), + ], + ); + expect(result.status).toBe("applied"); + expect(await validateIOSSDKInstallPostcondition(sdk.plan)).toBe(true); + expect(await validateIOSMissingEntitlementsSettingsPostcondition(entitlementsPlan)).toBe(true); + }); + + test("produces deterministic candidate bytes", async () => { + const firstRoot = await makeSynchronizedFixture(); + const secondRoot = await makeSynchronizedFixture(); + const first = await prepareIOSMissingEntitlementsSettingsMutation( + await planIOSMissingEntitlementsSettings(options(firstRoot)), + ); + const second = await prepareIOSMissingEntitlementsSettingsMutation( + await planIOSMissingEntitlementsSettings(options(secondRoot)), + ); + expect(first.status).toBe("ready"); + expect(second.status).toBe("ready"); + if (first.status !== "ready" || second.status !== "ready") { + throw new Error("Expected deterministic mutations."); + } + expect(first.mutation.candidateBytes).toEqual(second.mutation.candidateBytes); + }); + + test("blocks missing, ambiguous, shared, and generated synchronized-root ownership", async () => { + const missingRoot = await temporaryRoot(); + await createIOSFixture(missingRoot, { releaseEntitlements: false }); + const missingGraph = mutableProject(await readFile(pbxprojPath(missingRoot), "utf8")); + delete settings(missingGraph.objects, IOS_FIXTURE_IDS.targetDebug).CODE_SIGN_ENTITLEMENTS; + delete missingGraph.objects[IOS_FIXTURE_IDS.entitlementsFile]; + missingGraph.objects[IOS_FIXTURE_IDS.mainGroup]!.children = ( + missingGraph.objects[IOS_FIXTURE_IDS.mainGroup]!.children as string[] + ).filter((id) => id !== IOS_FIXTURE_IDS.entitlementsFile); + await writeFile(pbxprojPath(missingRoot), buildPbxProject(missingGraph.project)); + await rm(entitlementsPath(missingRoot)); + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(missingRoot)))).toContain( + "missing-synchronized-root", + ); + + const ambiguousRoot = await makeSynchronizedFixture(); + const ambiguousGraph = mutableProject(await readFile(pbxprojPath(ambiguousRoot), "utf8")); + const secondRootId = "525252525252525252525252"; + ambiguousGraph.objects[secondRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "Other", + sourceTree: "", + }; + ambiguousGraph.objects[IOS_FIXTURE_IDS.mainGroup]!.children = [ + ...(ambiguousGraph.objects[IOS_FIXTURE_IDS.mainGroup]!.children as string[]), + secondRootId, + ]; + ambiguousGraph.objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [ + SYNCHRONIZED_ROOT_ID, + secondRootId, + ]; + await writeFile(pbxprojPath(ambiguousRoot), buildPbxProject(ambiguousGraph.project)); + expect( + blockerCodes(await planIOSMissingEntitlementsSettings(options(ambiguousRoot))), + ).toContain("ambiguous-synchronized-root"); + + const sharedRoot = await makeSynchronizedFixture({ secondTarget: true, shareRoot: true }); + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(sharedRoot)))).toContain( + "shared-synchronized-root", + ); + + const generatedRoot = await makeSynchronizedFixture(); + await writeFile(join(generatedRoot, "project.yml"), "name: MyApp\n"); + expect( + blockerCodes(await planIOSMissingEntitlementsSettings(options(generatedRoot))), + ).toContain("generated-project"); + }); + + test.each(["regular", "directory", "symlink", "hardlink"] as const)( + "refuses an unreferenced %s destination collision", + async (kind) => { + const root = await makeSynchronizedFixture(); + const destination = entitlementsPath(root); + if (kind === "regular") await writeFile(destination, "existing"); + if (kind === "directory") await mkdir(destination); + if (kind === "symlink") { + const source = join(root, "MyApp", "Other.entitlements"); + await writeFile(source, "existing"); + await symlink(source, destination); + } + if (kind === "hardlink") { + const source = join(root, "MyApp", "Other.entitlements"); + await writeFile(source, "existing"); + await link(source, destination); + } + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "entitlements-destination-exists", + ); + }, + ); + + test("refuses a classic file reference and partial iOS settings", async () => { + const referenceRoot = await makeSynchronizedFixture({ retainClassicReference: true }); + expect( + blockerCodes(await planIOSMissingEntitlementsSettings(options(referenceRoot))), + ).toContain("entitlements-destination-exists"); + + const partialRoot = await makeSynchronizedFixture(); + const graph = mutableProject(await readFile(pbxprojPath(partialRoot), "utf8")); + settings(graph.objects, IOS_FIXTURE_IDS.targetDebug)[DEVICE_SETTING] = + "MyApp/MyApp.entitlements"; + await writeFile(pbxprojPath(partialRoot), buildPbxProject(graph.project)); + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(partialRoot)))).toContain( + "conflicting-entitlements-settings", + ); + }); + + test("refuses an entitlements destination referenced by another target", async () => { + const root = await makeSynchronizedFixture({ secondTarget: true }); + const graph = mutableProject(await readFile(pbxprojPath(root), "utf8")); + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + settings(graph.objects, id).CODE_SIGN_ENTITLEMENTS = "MyApp/MyApp.entitlements"; + } + await writeFile(pbxprojPath(root), buildPbxProject(graph.project)); + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "shared-entitlements-destination", + ); + }); + + test("refuses a destination represented by a classic reference in another project", async () => { + const root = await makeSynchronizedFixture(); + await createCrossProjectClassicReference(root); + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "entitlements-destination-exists", + ); + }); + + test("refuses a sibling synchronized root that is an ancestor of the destination", async () => { + const root = await makeSynchronizedFixture({ secondTarget: true }); + const graph = mutableProject(await readFile(pbxprojPath(root), "utf8")); + graph.objects[ANCESTOR_SYNCHRONIZED_ROOT_ID] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: ".", + sourceTree: "", + }; + graph.objects[IOS_FIXTURE_IDS.mainGroup]!.children = [ + ...(graph.objects[IOS_FIXTURE_IDS.mainGroup]!.children as string[]), + ANCESTOR_SYNCHRONIZED_ROOT_ID, + ]; + graph.objects[IOS_FIXTURE_IDS.secondTarget]!.fileSystemSynchronizedGroups = [ + ANCESTOR_SYNCHRONIZED_ROOT_ID, + ]; + await writeFile(pbxprojPath(root), buildPbxProject(graph.project)); + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "shared-synchronized-root", + ); + }); + + test("blocks a Git-ignored destination and honors a targeted negation", async () => { + const ignoredRoot = await makeSynchronizedFixture(); + await initializeGitRepository(ignoredRoot); + await writeFile(join(ignoredRoot, ".gitignore"), "*.entitlements\n"); + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(ignoredRoot)))).toContain( + "ignored-entitlements-destination", + ); + + const includedRoot = await makeSynchronizedFixture(); + await initializeGitRepository(includedRoot); + await writeFile( + join(includedRoot, ".gitignore"), + "*.entitlements\n!MyApp/MyApp.entitlements\n", + ); + expect(await planIOSMissingEntitlementsSettings(options(includedRoot))).toMatchObject({ + status: "ready", + blockers: [], + }); + }); + + test("postcondition rejects replacement of the synchronized root directory", async () => { + const root = await makeSynchronizedFixture(); + const plan = await planIOSMissingEntitlementsSettings(options(root)); + const prepared = await prepareIOSMissingEntitlementsSettingsMutation(plan); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("Expected a prepared mutation."); + expect((await applyIOSExistingFileTransaction([prepared.mutation], [() => true])).status).toBe( + "applied", + ); + + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await readFile(sourcePath); + await rename(join(root, "MyApp"), join(root, "MyApp-replaced")); + await mkdir(join(root, "MyApp")); + await writeFile(sourcePath, source); + + expect(await validateIOSMissingEntitlementsSettingsPostcondition(plan)).toBe(false); + }); + + test("treats project, destination, and base-mutation races as stale", async () => { + const projectRoot = await makeSynchronizedFixture(); + const projectPlan = await planIOSMissingEntitlementsSettings(options(projectRoot)); + const newerProject = await readFile(pbxprojPath(projectRoot)); + await appendFile(pbxprojPath(projectRoot), "\n// newer\n"); + expect((await prepareIOSMissingEntitlementsSettingsMutation(projectPlan)).status).toBe("stale"); + expect(await readFile(pbxprojPath(projectRoot))).not.toEqual(newerProject); + + const destinationRoot = await makeSynchronizedFixture(); + const destinationPlan = await planIOSMissingEntitlementsSettings(options(destinationRoot)); + await writeFile(entitlementsPath(destinationRoot), "newer"); + expect((await prepareIOSMissingEntitlementsSettingsMutation(destinationPlan)).status).toBe( + "stale", + ); + expect(await readFile(entitlementsPath(destinationRoot), "utf8")).toBe("newer"); + + const baseRoot = await makeSynchronizedFixture(); + const basePlan = await planIOSMissingEntitlementsSettings(options(baseRoot)); + const bytes = new Uint8Array(await readFile(pbxprojPath(baseRoot))); + const invalidBase: IOSExistingFileMutation = { + path: join(baseRoot, "Other.xcodeproj", "project.pbxproj"), + originalBytes: bytes, + originalHash: hashIOSFileBytes(bytes), + candidateBytes: bytes, + candidateHash: hashIOSFileBytes(bytes), + mode: 0o644, + }; + expect( + (await prepareIOSMissingEntitlementsSettingsMutation(basePlan, invalidBase)).status, + ).toBe("stale"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts new file mode 100644 index 000000000..e062ba683 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -0,0 +1,1498 @@ +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { inspectTargetBuildConfigurations } from "./build-settings.ts"; +import { + discoverIOSContainers, + inspectWorkspace, + pathIsSafelyWithinIOSRoot, + relativeIOSPath, +} from "./discovery.ts"; +import { hashIOSFileBytes, type IOSExistingFileMutation } from "./file-transaction.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { + asString, + buildPbxParentIndex, + isRecord, + resolvePbxFilePath, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; +import type { IOSDiagnostic } from "./types.ts"; + +const APP_PRODUCT_TYPE = "com.apple.product-type.application"; +const DEVICE_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"; +const SIMULATOR_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"; +const MAX_PBXPROJ_BYTES = 15_000_000; + +export interface IOSMissingEntitlementsSettingsOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; +} + +export type IOSMissingEntitlementsSettingsBlockerCode = + | "invalid-selection" + | "external-path" + | "generated-project" + | "unreadable-project" + | "malformed-project" + | "target-not-found" + | "incomplete-build-configurations" + | "missing-synchronized-root" + | "ambiguous-synchronized-root" + | "unsafe-synchronized-root" + | "shared-synchronized-root" + | "invalid-entitlements-destination" + | "entitlements-destination-exists" + | "ignored-entitlements-destination" + | "unresolved-git-ignore" + | "shared-entitlements-destination" + | "conflicting-entitlements-settings" + | "unsupported-project"; + +export interface IOSMissingEntitlementsSettingsBlocker { + code: IOSMissingEntitlementsSettingsBlockerCode; + message: string; +} + +interface IOSMissingEntitlementsSettingsPlanBase { + schemaVersion: 1; + kind: "clerk-ios-missing-entitlements-settings"; + root: string; + projectPath: string; + targetId: string; + /** Exact target configuration IDs authorized by this plan, when inspectable. */ + configurationIds: string[]; + actions: string[]; + blockers: IOSMissingEntitlementsSettingsBlocker[]; +} + +interface IOSMissingEntitlementsSettingsResolvedFields { + targetName: string; + /** Invocation-root-relative destination. */ + entitlementsPath: string; + /** Value written to CODE_SIGN_ENTITLEMENTS, relative to the .xcodeproj directory. */ + buildSettingPath: string; + /** Invocation-root-relative synchronized target root. */ + synchronizedRootPath: string; + synchronizedRootObjectId: string; + expectedSynchronizedRootIdentity: { device: number; inode: number }; + expectedPbxprojHash: string; + expectedPbxprojMode: number; +} + +export type IOSMissingEntitlementsSettingsPlan = + | (IOSMissingEntitlementsSettingsPlanBase & + IOSMissingEntitlementsSettingsResolvedFields & { status: "ready" }) + | (IOSMissingEntitlementsSettingsPlanBase & + IOSMissingEntitlementsSettingsResolvedFields & { status: "satisfied" }) + | (IOSMissingEntitlementsSettingsPlanBase & + Partial & { status: "blocked" }); + +interface IOSMissingEntitlementsSettingsPlanSource { + targetName?: string; + entitlementsPath?: string; + buildSettingPath?: string; + synchronizedRootPath?: string; + synchronizedRootObjectId?: string; + expectedSynchronizedRootIdentity?: { device: number; inode: number }; + expectedPbxprojHash?: string; + expectedPbxprojMode?: number; +} + +export type PreparedIOSMissingEntitlementsSettingsMutation = + | { status: "ready"; plan: IOSMissingEntitlementsSettingsPlan; mutation: IOSExistingFileMutation } + | { status: "satisfied"; plan: IOSMissingEntitlementsSettingsPlan } + | { status: "blocked"; plan: IOSMissingEntitlementsSettingsPlan } + | { status: "stale"; plan: IOSMissingEntitlementsSettingsPlan }; + +interface ProjectGraph { + project: ReturnType; + objects: PbxObjects; + projectObjectId: string; + projectObject: PbxObject; + targetId: string; + targetObject: PbxObject; + configurationIds: string[]; +} + +interface ProjectSnapshot { + absoluteProjectPath: string; + pbxprojPath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + graph: ProjectGraph; +} + +interface SynchronizedRoot { + objectId: string; + absolutePath: string; + relativePath: string; + device: number; + inode: number; +} + +function blocker( + code: IOSMissingEntitlementsSettingsBlockerCode, + message: string, +): IOSMissingEntitlementsSettingsBlocker { + return { code, message }; +} + +function planBase( + options: IOSMissingEntitlementsSettingsOptions, +): Pick< + IOSMissingEntitlementsSettingsPlanBase, + "schemaVersion" | "kind" | "root" | "projectPath" | "targetId" +> { + return { + schemaVersion: 1, + kind: "clerk-ios-missing-entitlements-settings", + root: resolve(options.root), + projectPath: options.projectPath.replaceAll("\\", "/"), + targetId: options.targetId, + }; +} + +function blockedPlan( + options: IOSMissingEntitlementsSettingsOptions, + detail: IOSMissingEntitlementsSettingsBlocker, + source: IOSMissingEntitlementsSettingsPlanSource & { configurationIds?: string[] } = {}, +): IOSMissingEntitlementsSettingsPlan { + return { + ...planBase(options), + ...source, + status: "blocked", + configurationIds: source.configurationIds ?? [], + actions: [], + blockers: [detail], + }; +} + +function blockPrepared( + plan: IOSMissingEntitlementsSettingsPlan, + code: IOSMissingEntitlementsSettingsBlockerCode, + message: string, +): PreparedIOSMissingEntitlementsSettingsMutation { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [blocker(code, message)], + }, + }; +} + +function exactStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return undefined; + const result = [...value]; + return new Set(result).size === result.length ? result : undefined; +} + +function optionalExactStringArray(value: unknown): string[] | undefined { + return value == null ? [] : exactStringArray(value); +} + +function normalizedObjects(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 as PbxObject; + } + return objects; +} + +function projectGraph( + project: ReturnType, + targetId: string, +): ProjectGraph | undefined { + const archive: unknown = project; + if (!isRecord(archive)) return undefined; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + const targetObject = objects?.[targetId]; + if ( + !objects || + !projectObjectId || + projectObject?.isa !== "PBXProject" || + targetObject?.isa !== "PBXNativeTarget" || + asString(targetObject.productType) !== APP_PRODUCT_TYPE + ) { + return undefined; + } + const configurationListId = asString(targetObject.buildConfigurationList); + const configurationList = configurationListId ? objects[configurationListId] : undefined; + if (configurationList?.isa !== "XCConfigurationList") return undefined; + const configurationIds = exactStringArray(configurationList.buildConfigurations); + if ( + !configurationIds || + configurationIds.length === 0 || + configurationIds.some((id) => objects[id]?.isa !== "XCBuildConfiguration") + ) { + return undefined; + } + return { + project, + objects, + projectObjectId, + projectObject, + targetId, + targetObject, + configurationIds, + }; +} + +function validSuppliedSelection(options: IOSMissingEntitlementsSettingsOptions): boolean { + const projectPath = options.projectPath.replaceAll("\\", "/"); + return ( + options.targetId.trim().length > 0 && + projectPath.length > 0 && + !isAbsolute(options.projectPath) && + projectPath.endsWith(".xcodeproj") + ); +} + +async function readProjectSnapshot( + root: string, + projectPath: string, + targetId: string, +): Promise { + const absoluteProjectPath = resolve(root, projectPath); + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if ( + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || + !(await pathIsSafelyWithinIOSRoot(root, pbxprojPath)) + ) { + return undefined; + } + try { + const [projectInfo, info] = await Promise.all([lstat(absoluteProjectPath), lstat(pbxprojPath)]); + if ( + !projectInfo.isDirectory() || + projectInfo.isSymbolicLink() || + !info.isFile() || + info.isSymbolicLink() || + info.size > MAX_PBXPROJ_BYTES + ) { + return undefined; + } + const bytes = new Uint8Array(await readFile(pbxprojPath)); + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const project = parsePbxProject(source); + const graph = projectGraph(project, targetId); + if (!graph) return undefined; + return { + absoluteProjectPath, + pbxprojPath, + bytes, + hash: hashIOSFileBytes(bytes), + mode: info.mode & 0o7777, + source, + graph, + }; + } catch { + return undefined; + } +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [relativePath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, relativePath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +function parentReferenceCount(objects: PbxObjects, childId: string): number { + let count = 0; + for (const object of Object.values(objects)) { + const children = optionalExactStringArray(object.children); + if (children?.includes(childId)) count += 1; + } + return count; +} + +async function selectedSynchronizedRoot( + root: string, + snapshot: ProjectSnapshot, +): Promise<{ root?: SynchronizedRoot; blocker?: IOSMissingEntitlementsSettingsBlocker }> { + const groupIds = optionalExactStringArray( + snapshot.graph.targetObject.fileSystemSynchronizedGroups, + ); + if (!groupIds) { + return { + blocker: blocker( + "ambiguous-synchronized-root", + "The selected target has a malformed synchronized-folder list.", + ), + }; + } + if (groupIds.length === 0) { + return { + blocker: blocker( + "missing-synchronized-root", + "The selected target does not have a filesystem-synchronized source root.", + ), + }; + } + if (groupIds.length !== 1) { + return { + blocker: blocker( + "ambiguous-synchronized-root", + "The selected target has more than one filesystem-synchronized source root.", + ), + }; + } + const objectId = groupIds[0]!; + const group = snapshot.graph.objects[objectId]; + if ( + group?.isa !== "PBXFileSystemSynchronizedRootGroup" || + !asString(group.path)?.trim() || + parentReferenceCount(snapshot.graph.objects, objectId) !== 1 + ) { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root could not be resolved uniquely.", + ), + }; + } + const parents = buildPbxParentIndex(snapshot.graph.objects); + const projectDirectory = dirname(snapshot.absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(snapshot.graph.projectObject.projectDirPath) ?? "", + ); + const absolutePath = resolvePbxFilePath( + objectId, + snapshot.graph.objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!absolutePath || !(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root resolves outside the invocation root.", + ), + }; + } + try { + const info = await lstat(absolutePath); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsupported root"); + await realpath(absolutePath); + return { + root: { + objectId, + absolutePath, + relativePath: relativeIOSPath(root, absolutePath), + device: info.dev, + inode: info.ino, + }, + }; + } catch { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root must be a regular, non-symlink directory.", + ), + }; + } +} + +async function localProjectPaths(root: string, selectedProjectPath: string): Promise { + const containers = await discoverIOSContainers(root); + const paths = new Set([...containers.projectPaths, selectedProjectPath]); + for (const workspacePath of containers.workspacePaths) { + const workspace = await inspectWorkspace(root, workspacePath); + for (const projectPath of workspace.localProjectPaths) paths.add(projectPath); + } + return [...paths].sort(); +} + +async function synchronizedRootIsExclusive( + root: string, + selectedProjectPath: string, + selectedTargetId: string, + selectedRoot: SynchronizedRoot, + destination: string, +): Promise { + let selectedCanonical: string; + let canonicalDestination: string; + try { + selectedCanonical = await realpath(selectedRoot.absolutePath); + canonicalDestination = await canonicalPathWithPossibleMissingLeaf(destination); + } catch { + return false; + } + for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + let archive: unknown; + try { + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } + if (!isRecord(archive)) return false; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") return false; + const targetIds = exactStringArray(projectObject.targets); + if (!targetIds) return false; + const parents = buildPbxParentIndex(objects); + const projectDirectory = dirname(absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); + for (const targetId of targetIds) { + const target = objects[targetId]; + if (target?.isa !== "PBXNativeTarget") continue; + const groupIds = optionalExactStringArray(target.fileSystemSynchronizedGroups); + if (!groupIds) return false; + for (const groupId of groupIds) { + if ( + absoluteProjectPath === selectedProjectPath && + targetId === selectedTargetId && + groupId === selectedRoot.objectId + ) { + continue; + } + const group = objects[groupId]; + if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") return false; + const groupPath = resolvePbxFilePath( + groupId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!groupPath || !(await pathIsSafelyWithinIOSRoot(root, groupPath))) return false; + try { + const info = await lstat(groupPath); + if (!info.isDirectory() || info.isSymbolicLink()) return false; + const canonical = await realpath(groupPath); + if ( + canonical === selectedCanonical || + (info.dev === selectedRoot.device && info.ino === selectedRoot.inode) || + pathContains(canonical, canonicalDestination) + ) { + return false; + } + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) return false; + } + } + } + } + return true; +} + +async function canonicalPathWithPossibleMissingLeaf(path: string): Promise { + try { + return await realpath(path); + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) throw error; + return resolve(await realpath(dirname(path)), basename(path)); + } +} + +function pathContains(directory: string, candidate: string): boolean { + const relativePath = relative(directory, candidate); + return ( + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) + ); +} + +type GitIgnoreState = "not-repository" | "included" | "ignored" | "error"; + +async function findGitMarker( + start: string, +): Promise<{ state: "found"; directory: string } | { state: "none" | "error" }> { + let directory = resolve(start); + while (true) { + try { + await lstat(resolve(directory, ".git")); + return { state: "found", directory }; + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) return { state: "error" }; + } + const parent = dirname(directory); + if (parent === directory) return { state: "none" }; + directory = parent; + } +} + +async function runGit( + cwd: string, + args: readonly string[], +): Promise<{ exitCode: number; stdout: string } | undefined> { + try { + const child = Bun.spawn(["git", ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).arrayBuffer(); + const [exitCode, output] = await Promise.all([child.exited, stdout, stderr]).then( + ([code, text]) => [code, text] as const, + ); + return { exitCode, stdout: output }; + } catch { + return undefined; + } +} + +async function gitIgnoreState(destination: string): Promise { + const marker = await findGitMarker(dirname(destination)); + if (marker.state === "none") return "not-repository"; + if (marker.state === "error") return "error"; + + const repository = await runGit(dirname(destination), ["rev-parse", "--show-toplevel"]); + if (!repository || repository.exitCode !== 0) return "error"; + const lines = repository.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length !== 1 || !isAbsolute(lines[0]!)) return "error"; + + try { + const canonicalRepository = await realpath(lines[0]!); + const canonicalDestination = await canonicalPathWithPossibleMissingLeaf(destination); + if (!pathContains(canonicalRepository, canonicalDestination)) return "error"; + const repositoryRelativePath = relative(canonicalRepository, canonicalDestination) + .split(sep) + .join("/"); + if ( + !repositoryRelativePath || + repositoryRelativePath === ".." || + repositoryRelativePath.startsWith("../") || + isAbsolute(repositoryRelativePath) + ) { + return "error"; + } + const checked = await runGit(canonicalRepository, [ + "check-ignore", + "--quiet", + "--no-index", + "--", + repositoryRelativePath, + ]); + if (!checked) return "error"; + if (checked.exitCode === 0) return "ignored"; + if (checked.exitCode === 1) return "included"; + return "error"; + } catch { + return "error"; + } +} + +async function classicDestinationIsUnreferenced( + root: string, + selectedProjectPath: string, + destination: string, +): Promise { + const normalizedDestination = resolve(destination).toLocaleLowerCase("en-US"); + let canonicalDestination: string; + try { + canonicalDestination = ( + await canonicalPathWithPossibleMissingLeaf(destination) + ).toLocaleLowerCase("en-US"); + } catch { + return false; + } + for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + let archive: unknown; + try { + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } + if (!isRecord(archive)) return false; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") return false; + const parents = buildPbxParentIndex(objects); + const projectDirectory = dirname(absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); + for (const [objectId, object] of Object.entries(objects)) { + if (object.isa !== "PBXFileReference") continue; + const referencedPath = resolvePbxFilePath( + objectId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!referencedPath) continue; + if (referencedPath.toLocaleLowerCase("en-US") === normalizedDestination) return false; + try { + if ( + (await canonicalPathWithPossibleMissingLeaf(referencedPath)).toLocaleLowerCase( + "en-US", + ) === canonicalDestination + ) { + return false; + } + } catch { + // An unrelated unresolved reference cannot alias the existing parent + // of this exact destination without first becoming inspectable. + } + } + } + return true; +} + +async function entitlementsDestinationIsExclusive( + root: string, + selectedProjectPath: string, + selectedTargetId: string, + destination: string, +): Promise { + const normalizedDestination = resolve(destination).toLocaleLowerCase("en-US"); + for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + let archive: unknown; + try { + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } + if (!isRecord(archive)) return false; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") return false; + const targetIds = exactStringArray(projectObject.targets); + if (!targetIds) return false; + const parents = buildPbxParentIndex(objects); + const groupRootDirectory = resolve( + dirname(absoluteProjectPath), + asString(projectObject.projectDirPath) ?? "", + ); + for (const targetId of targetIds) { + if (absoluteProjectPath === selectedProjectPath && targetId === selectedTargetId) continue; + const targetObject = objects[targetId]; + if (targetObject?.isa !== "PBXNativeTarget") continue; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectTargetBuildConfigurations({ + root, + projectPath: absoluteProjectPath, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics, + }); + if ( + configurations.length === 0 || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + for (const configuration of configurations) { + const resolution = configuration.model.entitlementsPath; + if (resolution.state === "unresolved") return false; + if (resolution.state !== "resolved") continue; + const siblingPath = resolve(dirname(absoluteProjectPath), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + if (siblingPath.toLocaleLowerCase("en-US") === normalizedDestination) return false; + } + } + } + return true; +} + +function isFileSystemError(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + +function destinationForRoot( + root: string, + absoluteProjectPath: string, + synchronizedRoot: SynchronizedRoot, +): + | { absolutePath: string; relativePath: string; buildSettingPath: string } + | { blocker: IOSMissingEntitlementsSettingsBlocker } { + const rootName = basename(synchronizedRoot.absolutePath); + if ( + !rootName || + rootName === "." || + rootName === ".." || + rootName.includes("\0") || + rootName.length > 200 + ) { + return { + blocker: blocker( + "invalid-entitlements-destination", + "A deterministic entitlements filename could not be derived from the synchronized root.", + ), + }; + } + const absolutePath = resolve(synchronizedRoot.absolutePath, `${rootName}.entitlements`); + const projectDirectory = dirname(absoluteProjectPath); + const buildSettingPath = relative(projectDirectory, absolutePath).split(sep).join("/"); + if ( + dirname(absolutePath) !== synchronizedRoot.absolutePath || + !buildSettingPath || + buildSettingPath === ".." || + buildSettingPath.startsWith("../") || + isAbsolute(buildSettingPath) + ) { + return { + blocker: blocker( + "invalid-entitlements-destination", + "The derived entitlements destination is not safely inside the synchronized root.", + ), + }; + } + return { + absolutePath, + relativePath: relativeIOSPath(root, absolutePath), + buildSettingPath, + }; +} + +async function destinationState( + synchronizedRoot: SynchronizedRoot, + absolutePath: string, +): Promise<"absent" | "regular" | "unsupported" | "case-collision"> { + try { + const info = await lstat(absolutePath); + return info.isFile() && !info.isSymbolicLink() ? "regular" : "unsupported"; + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) return "unsupported"; + } + try { + const expectedName = basename(absolutePath).toLocaleLowerCase("en-US"); + const entries = await readdir(synchronizedRoot.absolutePath); + if (entries.some((entry) => entry.toLocaleLowerCase("en-US") === expectedName)) { + return "case-collision"; + } + return "absent"; + } catch { + return "unsupported"; + } +} + +function settingsDictionary( + graph: ProjectGraph, + configurationId: string, +): Record | undefined { + const settings = graph.objects[configurationId]?.buildSettings; + return isRecord(settings) ? settings : undefined; +} + +function rawSettingsAreExact(graph: ProjectGraph, buildSettingPath: string): boolean { + return graph.configurationIds.every((id) => { + const settings = settingsDictionary(graph, id); + return ( + settings?.[DEVICE_SETTING] === buildSettingPath && + settings?.[SIMULATOR_SETTING] === buildSettingPath + ); + }); +} + +async function buildSettingState( + root: string, + snapshot: ProjectSnapshot, + buildSettingPath: string, +): Promise<"missing" | "exact" | "conflicting" | "incomplete"> { + const diagnostics: IOSDiagnostic[] = []; + const parents = buildPbxParentIndex(snapshot.graph.objects); + const groupRootDirectory = resolve( + dirname(snapshot.absoluteProjectPath), + asString(snapshot.graph.projectObject.projectDirPath) ?? "", + ); + const inspected = await inspectTargetBuildConfigurations({ + root, + projectPath: snapshot.absoluteProjectPath, + groupRootDirectory, + projectObject: snapshot.graph.projectObject, + targetId: snapshot.graph.targetId, + targetObject: snapshot.graph.targetObject, + objects: snapshot.graph.objects, + parents, + diagnostics, + }); + if ( + inspected.length !== snapshot.graph.configurationIds.length || + inspected.length === 0 || + !inspected.some((configuration) => configuration.isIOS) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return "incomplete"; + } + if ( + inspected.every((configuration) => configuration.model.entitlementsPath.state === "missing") + ) { + return "missing"; + } + if ( + rawSettingsAreExact(snapshot.graph, buildSettingPath) && + inspected.every( + (configuration) => + configuration.model.entitlementsPath.state === "resolved" && + configuration.model.entitlementsPath.value === buildSettingPath, + ) + ) { + return "exact"; + } + return "conflicting"; +} + +async function inspectSelectedTarget( + root: string, + projectPath: string, + targetId: string, +): Promise { + const inspection = await inspectIOSProject(root, { target: targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== targetId || + inspection.selection.projectPath !== projectPath + ) { + return undefined; + } + return inspection.appTargets.find( + (target) => target.id === targetId && target.projectPath === projectPath, + )?.name; +} + +export async function planIOSMissingEntitlementsSettings( + options: IOSMissingEntitlementsSettingsOptions, +): Promise { + const root = resolve(options.root); + const normalizedProjectPath = options.projectPath.replaceAll("\\", "/"); + const normalizedOptions = { ...options, root, projectPath: normalizedProjectPath }; + if (!validSuppliedSelection(normalizedOptions)) { + return blockedPlan( + normalizedOptions, + blocker( + "invalid-selection", + "A selected root-relative .xcodeproj and target object ID are required.", + ), + ); + } + const absoluteProjectPath = resolve(root, normalizedProjectPath); + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if ( + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || + !(await pathIsSafelyWithinIOSRoot(root, pbxprojPath)) + ) { + return blockedPlan( + normalizedOptions, + blocker("external-path", "The selected Xcode project resolves outside the invocation root."), + ); + } + const snapshot = await readProjectSnapshot(root, normalizedProjectPath, options.targetId); + if (!snapshot) { + return blockedPlan( + normalizedOptions, + blocker( + "unreadable-project", + "The selected project.pbxproj is missing, malformed, symlinked, too large, or unreadable.", + ), + ); + } + const targetName = await inspectSelectedTarget(root, normalizedProjectPath, options.targetId); + if (!targetName) { + return blockedPlan( + normalizedOptions, + blocker( + "target-not-found", + "The selected object is not the exact inspected native iOS application target.", + ), + { + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + const synchronized = await selectedSynchronizedRoot(root, snapshot); + if (!synchronized.root) { + return blockedPlan(normalizedOptions, synchronized.blocker!, { + targetName, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }); + } + const destination = destinationForRoot(root, snapshot.absoluteProjectPath, synchronized.root); + if ("blocker" in destination) { + return blockedPlan(normalizedOptions, destination.blocker, { + targetName, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }); + } + if ( + !(await synchronizedRootIsExclusive( + root, + snapshot.absoluteProjectPath, + options.targetId, + synchronized.root, + destination.absolutePath, + )) + ) { + return blockedPlan( + normalizedOptions, + blocker( + "shared-synchronized-root", + "The synchronized source root is shared with another target, or exclusive ownership could not be proven.", + ), + { + targetName, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + if (!(await pathIsSafelyWithinIOSRoot(root, destination.absolutePath))) { + return blockedPlan( + normalizedOptions, + blocker( + "invalid-entitlements-destination", + "The entitlements destination resolves outside the invocation root.", + ), + { targetName, configurationIds: snapshot.graph.configurationIds }, + ); + } + const ignoreState = await gitIgnoreState(destination.absolutePath); + if (ignoreState === "ignored" || ignoreState === "error") { + return blockedPlan( + normalizedOptions, + blocker( + ignoreState === "ignored" ? "ignored-entitlements-destination" : "unresolved-git-ignore", + ignoreState === "ignored" + ? `${destination.relativePath} is ignored by Git. Add a targeted .gitignore negation before automatic setup.` + : `Git ignore status for ${destination.relativePath} could not be verified safely.`, + ), + { + targetName, + entitlementsPath: destination.relativePath, + buildSettingPath: destination.buildSettingPath, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + if ( + !(await classicDestinationIsUnreferenced( + root, + snapshot.absoluteProjectPath, + destination.absolutePath, + )) + ) { + return blockedPlan( + normalizedOptions, + blocker( + "entitlements-destination-exists", + "The intended entitlements destination is already represented by an Xcode file reference; it will not be adopted or overwritten.", + ), + { + targetName, + entitlementsPath: destination.relativePath, + buildSettingPath: destination.buildSettingPath, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + if ( + !(await entitlementsDestinationIsExclusive( + root, + snapshot.absoluteProjectPath, + options.targetId, + destination.absolutePath, + )) + ) { + return blockedPlan( + normalizedOptions, + blocker( + "shared-entitlements-destination", + "The intended entitlements destination is referenced by another target, or exclusive use could not be proven.", + ), + { + targetName, + entitlementsPath: destination.relativePath, + buildSettingPath: destination.buildSettingPath, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + const settingState = await buildSettingState(root, snapshot, destination.buildSettingPath); + const sharedPlanFields: IOSMissingEntitlementsSettingsResolvedFields & { + configurationIds: string[]; + } = { + targetName, + entitlementsPath: destination.relativePath, + buildSettingPath: destination.buildSettingPath, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }; + if (settingState === "incomplete") { + return blockedPlan( + normalizedOptions, + blocker( + "incomplete-build-configurations", + "Every selected-target build configuration and iOS build context must be inspectable before adding entitlements settings.", + ), + sharedPlanFields, + ); + } + if (settingState === "conflicting") { + return blockedPlan( + normalizedOptions, + blocker( + "conflicting-entitlements-settings", + "The selected target already has partial, inherited, unresolved, or conflicting iOS entitlements settings.", + ), + sharedPlanFields, + ); + } + const pathState = await destinationState(synchronized.root, destination.absolutePath); + if (settingState === "missing") { + if (pathState !== "absent") { + return blockedPlan( + normalizedOptions, + blocker( + "entitlements-destination-exists", + "The intended entitlements destination already exists or is represented by an incompatible Xcode file reference; it will not be adopted or overwritten.", + ), + sharedPlanFields, + ); + } + const generator = await generatedProjectKind(root, snapshot.absoluteProjectPath); + if (generator) { + return blockedPlan( + normalizedOptions, + blocker( + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated project.pbxproj output.`, + ), + sharedPlanFields, + ); + } + } else if (pathState === "unsupported" || pathState === "case-collision") { + return blockedPlan( + normalizedOptions, + blocker( + "invalid-entitlements-destination", + "The configured entitlements destination is a symlink, directory, case-colliding path, or unreadable entry.", + ), + sharedPlanFields, + ); + } + + return { + ...planBase(normalizedOptions), + ...sharedPlanFields, + status: settingState === "exact" ? "satisfied" : "ready", + configurationIds: snapshot.graph.configurationIds, + actions: + settingState === "exact" + ? [] + : [ + `Add iOS device and simulator CODE_SIGN_ENTITLEMENTS settings for ${destination.relativePath} to every selected-target build configuration.`, + ], + blockers: [], + }; +} + +function sameStringArray(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function sameResolvedPlanIdentity( + left: Extract, + right: Extract, +): boolean { + return ( + left.root === right.root && + left.projectPath === right.projectPath && + left.targetId === right.targetId && + left.entitlementsPath === right.entitlementsPath && + left.buildSettingPath === right.buildSettingPath && + left.synchronizedRootPath === right.synchronizedRootPath && + left.synchronizedRootObjectId === right.synchronizedRootObjectId && + left.expectedSynchronizedRootIdentity.device === + right.expectedSynchronizedRootIdentity.device && + left.expectedSynchronizedRootIdentity.inode === right.expectedSynchronizedRootIdentity.inode && + left.expectedPbxprojHash === right.expectedPbxprojHash && + left.expectedPbxprojMode === right.expectedPbxprojMode && + sameStringArray(left.configurationIds, right.configurationIds) + ); +} + +function synchronizedRootPathForGraph( + graph: ProjectGraph, + absoluteProjectPath: string, + synchronizedRootObjectId: string, +): string | undefined { + const group = graph.objects[synchronizedRootObjectId]; + if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") return undefined; + const projectDirectory = dirname(absoluteProjectPath); + return resolvePbxFilePath( + synchronizedRootObjectId, + graph.objects, + buildPbxParentIndex(graph.objects), + projectDirectory, + resolve(projectDirectory, asString(graph.projectObject.projectDirPath) ?? ""), + ); +} + +function preparedWithHiddenMutation( + plan: IOSMissingEntitlementsSettingsPlan, + mutation: IOSExistingFileMutation, +): Extract { + const result = { status: "ready" as const, plan } as Extract< + PreparedIOSMissingEntitlementsSettingsMutation, + { status: "ready" } + >; + Object.defineProperty(result, "mutation", { + value: mutation, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +function baseMutationIsValid(mutation: IOSExistingFileMutation): boolean { + return ( + hashIOSFileBytes(mutation.originalBytes) === mutation.originalHash && + hashIOSFileBytes(mutation.candidateBytes) === mutation.candidateHash && + Number.isInteger(mutation.mode) && + mutation.mode >= 0 && + mutation.mode <= 0o7777 + ); +} + +export async function prepareIOSMissingEntitlementsSettingsMutation( + plan: IOSMissingEntitlementsSettingsPlan, + baseMutation?: IOSExistingFileMutation, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if (plan.status === "satisfied") { + const current = await planIOSMissingEntitlementsSettings({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + if (current.status === "blocked") return { status: "blocked", plan: current }; + return current.status === "satisfied" && sameResolvedPlanIdentity(plan, current) + ? { status: "satisfied", plan: current } + : { status: "stale", plan }; + } + if ( + !plan.expectedPbxprojHash || + plan.expectedPbxprojMode == null || + !plan.entitlementsPath || + !plan.buildSettingPath || + !plan.synchronizedRootPath || + !plan.synchronizedRootObjectId || + !plan.expectedSynchronizedRootIdentity || + plan.configurationIds.length === 0 + ) { + return blockPrepared( + plan, + "unsupported-project", + "The serialized entitlements-settings plan is incomplete.", + ); + } + const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const entitlementsPath = resolve(plan.root, plan.entitlementsPath); + const synchronizedRootPath = resolve(plan.root, plan.synchronizedRootPath); + if ( + !(await pathIsSafelyWithinIOSRoot(plan.root, pbxprojPath)) || + !(await pathIsSafelyWithinIOSRoot(plan.root, entitlementsPath)) || + !(await pathIsSafelyWithinIOSRoot(plan.root, synchronizedRootPath)) + ) { + return blockPrepared( + plan, + "external-path", + "A planned Xcode or entitlements path no longer resolves safely inside the invocation root.", + ); + } + let currentBytes: Uint8Array; + try { + const [projectInfo, rootInfo] = await Promise.all([ + lstat(pbxprojPath), + lstat(synchronizedRootPath), + ]); + currentBytes = new Uint8Array(await readFile(pbxprojPath)); + if ( + !projectInfo.isFile() || + projectInfo.isSymbolicLink() || + (projectInfo.mode & 0o7777) !== plan.expectedPbxprojMode || + hashIOSFileBytes(currentBytes) !== plan.expectedPbxprojHash || + !rootInfo.isDirectory() || + rootInfo.isSymbolicLink() || + rootInfo.dev !== plan.expectedSynchronizedRootIdentity.device || + rootInfo.ino !== plan.expectedSynchronizedRootIdentity.inode + ) { + return { status: "stale", plan }; + } + } catch { + return { status: "stale", plan }; + } + try { + await lstat(entitlementsPath); + return { status: "stale", plan }; + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) return { status: "stale", plan }; + } + + const replanned = await planIOSMissingEntitlementsSettings({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if ( + replanned.status !== "ready" || + replanned.expectedPbxprojHash !== plan.expectedPbxprojHash || + replanned.entitlementsPath !== plan.entitlementsPath || + replanned.buildSettingPath !== plan.buildSettingPath || + replanned.synchronizedRootPath !== plan.synchronizedRootPath || + replanned.synchronizedRootObjectId !== plan.synchronizedRootObjectId || + !sameStringArray(replanned.configurationIds, plan.configurationIds) + ) { + return { status: "stale", plan }; + } + + if (baseMutation) { + if ( + resolve(baseMutation.path) !== pbxprojPath || + baseMutation.originalHash !== plan.expectedPbxprojHash || + baseMutation.mode !== plan.expectedPbxprojMode + ) { + return { status: "stale", plan }; + } + if (!baseMutationIsValid(baseMutation)) { + return blockPrepared( + plan, + "unsupported-project", + "The prepared base Xcode mutation is invalid.", + ); + } + } + + const sourceBytes = baseMutation?.candidateBytes ?? currentBytes; + let model: ReturnType; + let graph: ProjectGraph; + try { + model = parsePbxProject(new TextDecoder("utf-8", { fatal: true }).decode(sourceBytes)); + const parsedGraph = projectGraph(model, plan.targetId); + if (!parsedGraph) throw new Error("missing target graph"); + graph = parsedGraph; + } catch { + return blockPrepared( + plan, + "unsupported-project", + "The prepared Xcode candidate could not be parsed safely.", + ); + } + const groupIds = optionalExactStringArray(graph.targetObject.fileSystemSynchronizedGroups); + if ( + !groupIds || + groupIds.length !== 1 || + groupIds[0] !== plan.synchronizedRootObjectId || + !sameStringArray(graph.configurationIds, plan.configurationIds) || + synchronizedRootPathForGraph( + graph, + resolve(plan.root, plan.projectPath), + plan.synchronizedRootObjectId, + ) !== synchronizedRootPath + ) { + return blockPrepared( + plan, + "unsupported-project", + "The prepared Xcode candidate changed the selected target structure.", + ); + } + for (const configurationId of graph.configurationIds) { + const settings = settingsDictionary(graph, configurationId); + if (!settings) { + return blockPrepared( + plan, + "malformed-project", + "A selected-target build configuration has no mutable build-settings dictionary.", + ); + } + const device = settings[DEVICE_SETTING]; + const simulator = settings[SIMULATOR_SETTING]; + const absent = device == null && simulator == null; + const exact = device === plan.buildSettingPath && simulator === plan.buildSettingPath; + if (!absent && !exact) { + return blockPrepared( + plan, + "conflicting-entitlements-settings", + "The prepared Xcode candidate introduced partial or conflicting iOS entitlements settings.", + ); + } + settings[DEVICE_SETTING] = plan.buildSettingPath; + settings[SIMULATOR_SETTING] = plan.buildSettingPath; + } + + let candidate: string; + let reparsed: ReturnType; + try { + candidate = buildPbxProject(model); + reparsed = parsePbxProject(candidate); + } catch { + return blockPrepared( + plan, + "unsupported-project", + "The proposed Xcode project could not be serialized and reparsed safely.", + ); + } + if (!isDeepStrictEqual(reparsed, model)) { + return blockPrepared( + plan, + "unsupported-project", + "Serializing the proposed Xcode project would change unsupported object-graph data.", + ); + } + const candidateGraph = projectGraph(reparsed, plan.targetId); + if ( + !candidateGraph || + !sameStringArray(candidateGraph.configurationIds, plan.configurationIds) || + !rawSettingsAreExact(candidateGraph, plan.buildSettingPath) + ) { + return blockPrepared( + plan, + "unsupported-project", + "The proposed Xcode project did not retain every required iOS entitlements setting.", + ); + } + const candidateBytes = new TextEncoder().encode(candidate); + return preparedWithHiddenMutation(plan, { + path: pbxprojPath, + originalBytes: baseMutation?.originalBytes ?? currentBytes, + originalHash: baseMutation?.originalHash ?? plan.expectedPbxprojHash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: baseMutation?.mode ?? plan.expectedPbxprojMode, + }); +} + +export async function validateIOSMissingEntitlementsSettingsPostcondition( + plan: IOSMissingEntitlementsSettingsPlan, +): Promise { + if (plan.status === "blocked") return false; + const current = await planIOSMissingEntitlementsSettings({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return ( + current.status === "satisfied" && + current.entitlementsPath === plan.entitlementsPath && + current.buildSettingPath === plan.buildSettingPath && + current.synchronizedRootPath === plan.synchronizedRootPath && + current.synchronizedRootObjectId === plan.synchronizedRootObjectId && + current.expectedSynchronizedRootIdentity.device === + plan.expectedSynchronizedRootIdentity.device && + current.expectedSynchronizedRootIdentity.inode === + plan.expectedSynchronizedRootIdentity.inode && + sameStringArray(current.configurationIds, plan.configurationIds) + ); +} diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts new file mode 100644 index 000000000..6d8a377cb --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -0,0 +1,745 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { + appendFile, + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + stat, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import { + applyIOSSDKInstall, + DEFAULT_CLERK_IOS_MINIMUM_VERSION, + planIOSSDKInstall, + PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION, + prepareIOSSDKInstallMutation, + type IOSSDKInstallBlockerCode, + validateIOSSDKInstallPostcondition, +} from "./install-sdk.ts"; +import { applyIOSExistingFileTransaction } from "./file-transaction.ts"; +import { type PbxObject, type PbxObjects } from "./pbx.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +interface MutableGraph { + project: ReturnType; + objects: PbxObjects; + root: PbxObject; + target: PbxObject; + frameworks: PbxObject; +} + +async function temporaryRoot(prefix = "clerk-ios-install-"): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(root); + return root; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, options); + return root; +} + +async function writePackageResolution(root: string, version: string): Promise { + const directory = join(root, "MyApp.xcodeproj", "project.xcworkspace", "xcshareddata", "swiftpm"); + await mkdir(directory, { recursive: true }); + await Bun.write( + join(directory, "Package.resolved"), + JSON.stringify({ + pins: [ + { + identity: "clerk-ios", + kind: "remoteSourceControl", + location: "https://github.com/clerk/clerk-ios", + state: { revision: "a".repeat(40), version }, + }, + ], + version: 3, + }), + ); +} + +async function writeLocalClerkPackageWithExcludedAuthAPIDecoys(root: string): Promise { + const packageRoot = join(root, "LocalClerk"); + const sources: Record = { + "Sources/ClerkKit/Core/Auth.swift": + "public struct Auth { public var events: AsyncStream { fatalError() } }\n", + "Sources/ClerkKit/Core/Clerk.swift": + "public struct Clerk { public func handle(_ url: URL) async throws -> Bool { true } }\n", + "Sources/ClerkKit/Domains/Auth/Session/Session.swift": + "public struct Session { public var tasks: [Task]? }\n", + "Sources/ClerkKit/Events/AuthEvent.swift": + "public enum AuthEvent { case signInNeedsContinuation; case signUpNeedsContinuation }\n", + "Sources/ClerkKit/Mocks/Clerk+Preview.swift": + "extension Clerk { public static func preview(preview: ((PreviewBuilder) -> Void)? = nil) -> Clerk { fatalError() } }\n", + "Sources/ClerkKitUI/Components/Auth/AuthView.swift": + "public struct AuthView: View { public init(mode: Mode = .signInOrUp, isDismissible: Bool = true) {} }\n", + "Sources/ClerkKitUI/Components/UserButton/UserButton.swift": + "public struct UserButton { public init(@ViewBuilder signedOutContent: () -> Content) {} }\n", + "Sources/ClerkKit/Compiled.swift": "public struct CompiledClerkKit {}\n", + "Sources/ClerkKitUI/Compiled.swift": "public struct CompiledClerkKitUI {}\n", + }; + for (const [relativePath, source] of Object.entries(sources)) { + const path = join(packageRoot, relativePath); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, source); + } + 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", path: "Sources/ClerkKit", sources: ["Compiled.swift"]), + .target(name: "ClerkKitUI", path: "Sources/ClerkKitUI", sources: ["Compiled.swift"]), + ] +) +`, + ); +} + +function pbxprojPath(root: string): string { + return join(root, "MyApp.xcodeproj", "project.pbxproj"); +} + +function mutableGraph(project: ReturnType): MutableGraph { + const archive = project as unknown as { rootObject: string; objects: PbxObjects }; + return { + project, + objects: archive.objects, + root: archive.objects[archive.rootObject]!, + target: archive.objects[IOS_FIXTURE_IDS.appTarget]!, + frameworks: archive.objects[IOS_FIXTURE_IDS.frameworksPhase]!, + }; +} + +async function transformProject( + root: string, + mutate: (graph: MutableGraph) => void, +): Promise { + const path = pbxprojPath(root); + const graph = mutableGraph(parsePbxProject(await readFile(path, "utf8"))); + mutate(graph); + await Bun.write(path, buildPbxProject(graph.project)); +} + +function removeClerkSDK(graph: MutableGraph): void { + graph.root.packageReferences = []; + graph.target.packageProductDependencies = []; + graph.frameworks.files = []; + for (const id of [ + IOS_FIXTURE_IDS.clerkPackage, + IOS_FIXTURE_IDS.clerkKit, + IOS_FIXTURE_IDS.clerkKitUI, + IOS_FIXTURE_IDS.clerkKitBuildFile, + IOS_FIXTURE_IDS.clerkKitUIBuildFile, + ]) { + delete graph.objects[id]; + } +} + +function installOptions(root: string, includeClerkKitUI = false) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + includeClerkKitUI, + }; +} + +function targetArraySnapshot(objects: PbxObjects, targetId: string): string { + const target = objects[targetId]!; + const buildPhases = Array.isArray(target.buildPhases) + ? target.buildPhases.filter((item): item is string => typeof item === "string") + : []; + return JSON.stringify({ + buildPhases: target.buildPhases, + buildRules: target.buildRules, + dependencies: target.dependencies, + packageProductDependencies: target.packageProductDependencies, + phaseFiles: Object.fromEntries( + buildPhases.map((phaseId) => [phaseId, objects[phaseId]?.files]), + ), + }); +} + +function installedObjectIds(root: string): Promise<{ + packageId: string; + productId: string; + buildFileId: string; +}> { + return readFile(pbxprojPath(root), "utf8").then((source) => { + const graph = mutableGraph(parsePbxProject(source)); + const packageEntry = Object.entries(graph.objects).find( + ([, object]) => + object.isa === "XCRemoteSwiftPackageReference" && + String(object.repositoryURL).includes("clerk/clerk-ios"), + ); + const productEntry = Object.entries(graph.objects).find( + ([, object]) => + object.isa === "XCSwiftPackageProductDependency" && object.productName === "ClerkKit", + ); + const buildFileEntry = Object.entries(graph.objects).find( + ([, object]) => object.isa === "PBXBuildFile" && object.productRef === productEntry?.[0], + ); + if (!packageEntry || !productEntry || !buildFileEntry) { + throw new Error("Installed Clerk graph is incomplete."); + } + return { + packageId: packageEntry[0], + productId: productEntry[0], + buildFileId: buildFileEntry[0], + }; + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS Clerk SDK installer", () => { + test("returns satisfied without serializing or changing a configured project", async () => { + const root = await fixture(); + const before = await readFile(pbxprojPath(root)); + + const plan = await planIOSSDKInstall(installOptions(root, true)); + expect(plan).toMatchObject({ + status: "satisfied", + minimumVersion: DEFAULT_CLERK_IOS_MINIMUM_VERSION, + products: ["ClerkKit", "ClerkKitUI"], + actions: [], + blockers: [], + }); + expect((await applyIOSSDKInstall(plan)).status).toBe("satisfied"); + expect(await readFile(pbxprojPath(root))).toEqual(before); + }); + + test("installs ClerkKit with stable IDs, preserves mode, and is byte-idempotent", async () => { + const firstRoot = await fixture(); + const secondRoot = await fixture(); + await transformProject(firstRoot, removeClerkSDK); + await transformProject(secondRoot, removeClerkSDK); + await chmod(pbxprojPath(firstRoot), 0o640); + + const firstPlan = await planIOSSDKInstall(installOptions(firstRoot)); + const secondPlan = await planIOSSDKInstall(installOptions(secondRoot)); + expect(firstPlan.status).toBe("ready"); + expect(secondPlan.status).toBe("ready"); + expect(firstPlan.actions).toEqual(secondPlan.actions); + expect((await applyIOSSDKInstall(firstPlan)).status).toBe("applied"); + expect((await applyIOSSDKInstall(secondPlan)).status).toBe("applied"); + + const firstIds = await installedObjectIds(firstRoot); + expect(firstIds).toEqual(await installedObjectIds(secondRoot)); + expect(Object.values(firstIds).every((id) => /^[A-F0-9]{24}$/.test(id))).toBe(true); + expect((await stat(pbxprojPath(firstRoot))).mode & 0o777).toBe(0o640); + + const inspection = await inspectIOSProject(firstRoot, { + target: IOS_FIXTURE_IDS.appTarget, + }); + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(inspection.projects[0]?.packages[0]).toMatchObject({ + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + }); + + const afterFirstApply = await readFile(pbxprojPath(firstRoot)); + const satisfied = await planIOSSDKInstall(installOptions(firstRoot)); + expect(satisfied.status).toBe("satisfied"); + expect((await applyIOSSDKInstall(satisfied)).status).toBe("satisfied"); + expect(await readFile(pbxprojPath(firstRoot))).toEqual(afterFirstApply); + }); + + test("uses the modern ClerkKitUI minimum for a new remote package", async () => { + const root = await fixture(); + await transformProject(root, removeClerkSDK); + + const plan = await planIOSSDKInstall({ + ...installOptions(root, true), + requirePrebuiltAuthCompatibility: true, + }); + + expect(plan).toMatchObject({ + status: "ready", + minimumVersion: PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION, + requirePrebuiltAuthCompatibility: true, + }); + expect(plan.actions[0]).toContain(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); + expect((await applyIOSSDKInstall(plan)).status).toBe("applied"); + const installedPackage = (await inspectIOSProject(root)).projects[0]?.packages[0]; + expect(installedPackage?.kind).toBe("remote"); + if (installedPackage?.kind !== "remote") throw new Error("Expected a remote Clerk package."); + expect(installedPackage.requirement).toMatchObject({ + kind: "upToNextMajorVersion", + minimumVersion: PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION, + }); + }); + + test("raises an explicitly older requested version to the prebuilt AuthView floor", async () => { + const root = await fixture(); + await transformProject(root, removeClerkSDK); + + const plan = await planIOSSDKInstall({ + ...installOptions(root, true), + minimumVersion: "0.70.0", + requirePrebuiltAuthCompatibility: true, + }); + + expect(plan.minimumVersion).toBe(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); + expect(plan.minimumVersion).not.toBe("0.70.0"); + }); + + test("blocks a remote package pinned before the modern ClerkKitUI products", async () => { + const root = await fixture(); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { + kind: "exactVersion", + version: "0.70.0", + }; + }); + const before = await treeDigest(root); + + const ordinaryPlan = await planIOSSDKInstall(installOptions(root, true)); + const prebuiltPlan = await planIOSSDKInstall({ + ...installOptions(root, true), + requirePrebuiltAuthCompatibility: true, + }); + + expect(ordinaryPlan.status).toBe("satisfied"); + expect(prebuiltPlan.status).toBe("blocked"); + expect(prebuiltPlan.blockers[0]?.code).toBe("incompatible-sdk"); + expect(prebuiltPlan.blockers[0]?.message).toContain(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); + expect(await treeDigest(root)).toEqual(before); + }); + + test("requires a compatible resolved pin when a remote range permits older SDKs", async () => { + const oldRoot = await fixture(); + await transformProject(oldRoot, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { + kind: "versionRange", + minimumVersion: "0.70.0", + maximumVersion: "2.0.0", + }; + }); + await writePackageResolution(oldRoot, "0.70.0"); + const oldPlan = await planIOSSDKInstall({ + ...installOptions(oldRoot, true), + requirePrebuiltAuthCompatibility: true, + }); + expect(oldPlan.status).toBe("blocked"); + expect(oldPlan.blockers[0]?.code).toBe("incompatible-sdk"); + + const compatibleRoot = await fixture(); + await transformProject(compatibleRoot, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { + kind: "versionRange", + minimumVersion: "0.70.0", + maximumVersion: "2.0.0", + }; + }); + await writePackageResolution(compatibleRoot, PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); + const compatiblePlan = await planIOSSDKInstall({ + ...installOptions(compatibleRoot, true), + requirePrebuiltAuthCompatibility: true, + }); + expect(compatiblePlan.status).toBe("satisfied"); + expect(await validateIOSSDKInstallPostcondition(compatiblePlan)).toBe(true); + }); + + test("does not trust API decoys excluded from a local package's compiled targets", async () => { + const root = await fixture(); + await writeLocalClerkPackageWithExcludedAuthAPIDecoys(root); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage] = { + isa: "XCLocalSwiftPackageReference", + relativePath: "LocalClerk", + }; + }); + + const plan = await planIOSSDKInstall({ + ...installOptions(root, true), + requirePrebuiltAuthCompatibility: true, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toEqual([ + expect.objectContaining({ + code: "incompatible-sdk", + message: expect.stringContaining("compiled target membership cannot be proven"), + }), + ]); + }); + + test("prepares an internal SDK mutation for a combined transaction", async () => { + const root = await fixture(); + await transformProject(root, removeClerkSDK); + const before = await readFile(pbxprojPath(root)); + const plan = await planIOSSDKInstall(installOptions(root, true)); + + const prepared = await prepareIOSSDKInstallMutation(plan); + + expect(prepared.status).toBe("ready"); + expect(await readFile(pbxprojPath(root))).toEqual(before); + if (prepared.status !== "ready") throw new Error("Expected a prepared SDK mutation."); + expect(await validateIOSSDKInstallPostcondition(prepared.plan)).toBe(false); + expect(prepared.mutation.path).toBe(pbxprojPath(root)); + expect(JSON.stringify(prepared.plan)).not.toContain("candidateBytes"); + + const result = await applyIOSExistingFileTransaction( + [prepared.mutation], + [() => validateIOSSDKInstallPostcondition(prepared.plan)], + ); + expect(result.status).toBe("applied"); + expect(await validateIOSSDKInstallPostcondition(prepared.plan)).toBe(true); + expect((await inspectIOSProject(root)).appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test("links only the selected independent second application target", async () => { + const root = await fixture({ secondTarget: true, clerkSDK: false }); + const before = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + const primaryArrays = targetArraySnapshot(before.objects, IOS_FIXTURE_IDS.appTarget); + expect(before.objects[IOS_FIXTURE_IDS.secondTarget]?.buildPhases).toEqual([ + IOS_FIXTURE_IDS.secondSourcesPhase, + IOS_FIXTURE_IDS.secondFrameworksPhase, + ]); + expect(before.objects[IOS_FIXTURE_IDS.secondSourcesPhase]?.files).toEqual([ + IOS_FIXTURE_IDS.secondSourceBuildFile, + ]); + expect(before.objects[IOS_FIXTURE_IDS.secondFrameworksPhase]?.files).toEqual([]); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.secondTarget, + }); + expect(plan.status).toBe("ready"); + expect((await applyIOSSDKInstall(plan)).status).toBe("applied"); + + const after = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + expect(targetArraySnapshot(after.objects, IOS_FIXTURE_IDS.appTarget)).toBe(primaryArrays); + + const secondProductIds = after.objects[IOS_FIXTURE_IDS.secondTarget] + ?.packageProductDependencies as string[]; + expect(secondProductIds).toHaveLength(1); + expect(after.objects[secondProductIds[0]!]).toMatchObject({ + isa: "XCSwiftPackageProductDependency", + productName: "ClerkKit", + }); + const secondFrameworkFiles = after.objects[IOS_FIXTURE_IDS.secondFrameworksPhase] + ?.files as string[]; + expect(secondFrameworkFiles).toHaveLength(1); + expect(after.objects[secondFrameworkFiles[0]!]).toMatchObject({ + isa: "PBXBuildFile", + productRef: secondProductIds[0], + }); + + const primaryInspection = await inspectIOSProject(root, { + target: IOS_FIXTURE_IDS.appTarget, + }); + const secondInspection = await inspectIOSProject(root, { + target: IOS_FIXTURE_IDS.secondTarget, + }); + expect(primaryInspection.appTargets[0]?.packages.clerkKit).toBe("absent"); + expect(secondInspection.appTargets[0]?.packages.clerkKit).toBe("linked"); + }); + + test("optionally installs ClerkKitUI and repairs a declared but unlinked product", async () => { + const cleanRoot = await fixture(); + await transformProject(cleanRoot, removeClerkSDK); + const uiPlan = await planIOSSDKInstall(installOptions(cleanRoot, true)); + expect(uiPlan.status).toBe("ready"); + expect((await applyIOSSDKInstall(uiPlan)).status).toBe("applied"); + expect((await inspectIOSProject(cleanRoot)).appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + + const repairRoot = await fixture(); + await transformProject(repairRoot, (graph) => { + graph.frameworks.files = [IOS_FIXTURE_IDS.clerkKitUIBuildFile]; + delete graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]; + }); + const repairPlan = await planIOSSDKInstall(installOptions(repairRoot)); + expect(repairPlan.status).toBe("ready"); + expect(repairPlan.actions).toEqual([ + "Link ClerkKit in the selected target's Frameworks phase.", + ]); + expect((await applyIOSSDKInstall(repairPlan)).status).toBe("applied"); + expect((await inspectIOSProject(repairRoot)).appTargets[0]?.packages.clerkKit).toBe("linked"); + + const missingPhaseRoot = await fixture(); + await transformProject(missingPhaseRoot, (graph) => { + removeClerkSDK(graph); + graph.target.buildPhases = [IOS_FIXTURE_IDS.sourcesPhase]; + delete graph.objects[IOS_FIXTURE_IDS.frameworksPhase]; + }); + const missingPhasePlan = await planIOSSDKInstall(installOptions(missingPhaseRoot)); + expect(missingPhasePlan.actions).toContain( + "Create a Frameworks build phase for the selected target.", + ); + expect((await applyIOSSDKInstall(missingPhasePlan)).status).toBe("applied"); + expect((await inspectIOSProject(missingPhaseRoot)).appTargets[0]?.packages.clerkKit).toBe( + "linked", + ); + }); + + test("reuses a verified local package and canonical remote URL variants", async () => { + const localRoot = await fixture(); + await mkdir(join(localRoot, "LocalClerk", "Sources", "ClerkKit"), { recursive: true }); + await mkdir(join(localRoot, "LocalClerk", "Sources", "ClerkKitUI"), { recursive: true }); + await Bun.write( + join(localRoot, "LocalClerk", "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", path: "Sources/ClerkKit"), + .target(name: "ClerkKitUI", path: "Sources/ClerkKitUI"), + ] +) +`, + ); + await transformProject(localRoot, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage] = { + isa: "XCLocalSwiftPackageReference", + relativePath: "LocalClerk", + }; + graph.root.packageReferences = []; + }); + const localPlan = await planIOSSDKInstall(installOptions(localRoot)); + expect(localPlan).toMatchObject({ status: "ready" }); + expect(localPlan.actions).toEqual([ + "Attach the verified clerk-ios package reference to the Xcode project.", + ]); + expect((await applyIOSSDKInstall(localPlan)).status).toBe("applied"); + expect((await inspectIOSProject(localRoot)).appTargets[0]?.packages.package).toBe("local"); + + const remoteRoot = await fixture(); + await transformProject(remoteRoot, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.repositoryURL = + "git@github.com:clerk/clerk-ios.git"; + }); + const before = await readFile(pbxprojPath(remoteRoot)); + const remotePlan = await planIOSSDKInstall(installOptions(remoteRoot, true)); + expect(remotePlan.status).toBe("satisfied"); + expect((await applyIOSSDKInstall(remotePlan)).status).toBe("satisfied"); + expect(await readFile(pbxprojPath(remoteRoot))).toEqual(before); + }); + + test("does not trust Clerk product names mentioned only in a local manifest comment", async () => { + const root = await fixture(); + await mkdir(join(root, "UnrelatedPackage")); + await Bun.write( + join(root, "UnrelatedPackage", "Package.swift"), + '// swift-tools-version: 6.0\n// Package(name: "Clerk", products: [.library(name: "ClerkKit", targets: ["ClerkKit"])])\n', + ); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage] = { + isa: "XCLocalSwiftPackageReference", + relativePath: "UnrelatedPackage", + }; + }); + + const before = await readFile(pbxprojPath(root)); + const plan = await planIOSSDKInstall(installOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("wrong-package"); + expect(await readFile(pbxprojPath(root))).toEqual(before); + }); + + test("blocks unsafe or ambiguous selected-target graphs without writing", async () => { + const cases: Array<{ + code: IOSSDKInstallBlockerCode; + mutate: (graph: MutableGraph) => void; + }> = [ + { + code: "unattributed-product", + mutate: (graph) => { + delete graph.objects[IOS_FIXTURE_IDS.clerkKit]!.package; + }, + }, + { + code: "wrong-package", + mutate: (graph) => { + const wrongPackage = "919191919191919191919191"; + graph.objects[wrongPackage] = { + isa: "XCRemoteSwiftPackageReference", + repositoryURL: "https://github.com/example/not-clerk", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + }; + graph.root.packageReferences = [IOS_FIXTURE_IDS.clerkPackage, wrongPackage]; + graph.objects[IOS_FIXTURE_IDS.clerkKit]!.package = wrongPackage; + }, + }, + { + code: "ambiguous-package", + mutate: (graph) => { + const secondPackage = "929292929292929292929292"; + graph.objects[secondPackage] = { + ...graph.objects[IOS_FIXTURE_IDS.clerkPackage]!, + }; + graph.root.packageReferences = [IOS_FIXTURE_IDS.clerkPackage, secondPackage]; + }, + }, + { + code: "duplicate-package", + mutate: (graph) => { + graph.root.packageReferences = [ + IOS_FIXTURE_IDS.clerkPackage, + IOS_FIXTURE_IDS.clerkPackage, + ]; + }, + }, + { + code: "duplicate-product", + mutate: (graph) => { + graph.target.packageProductDependencies = [ + IOS_FIXTURE_IDS.clerkKit, + IOS_FIXTURE_IDS.clerkKit, + ]; + }, + }, + { + code: "duplicate-build-file", + mutate: (graph) => { + graph.frameworks.files = [ + IOS_FIXTURE_IDS.clerkKitBuildFile, + IOS_FIXTURE_IDS.clerkKitBuildFile, + ]; + }, + }, + { + code: "ambiguous-frameworks-phase", + mutate: (graph) => { + const secondPhase = "939393939393939393939393"; + graph.objects[secondPhase] = { ...graph.frameworks, files: [] }; + graph.target.buildPhases = [ + IOS_FIXTURE_IDS.sourcesPhase, + IOS_FIXTURE_IDS.frameworksPhase, + secondPhase, + ]; + }, + }, + { + code: "unsupported-project", + mutate: (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "futureOS"; + }, + }, + { + code: "unsupported-project", + mutate: (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { + kind: "upToNextMajorVersion", + }; + }, + }, + ]; + + for (const item of cases) { + const root = await fixture(); + await transformProject(root, item.mutate); + const before = await treeDigest(root); + const plan = await planIOSSDKInstall(installOptions(root)); + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe(item.code); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("allows generated no-ops but blocks generated writes and project symlink escapes", async () => { + const satisfiedGeneratedRoot = await fixture(); + await Bun.write(join(satisfiedGeneratedRoot, "project.yml"), "name: MyApp\n"); + expect((await planIOSSDKInstall(installOptions(satisfiedGeneratedRoot))).status).toBe( + "satisfied", + ); + + const generatedRoot = await fixture(); + await transformProject(generatedRoot, removeClerkSDK); + await Bun.write(join(generatedRoot, "project.yml"), "name: MyApp\n"); + const generatedBefore = await treeDigest(generatedRoot); + const generatedPlan = await planIOSSDKInstall(installOptions(generatedRoot)); + expect(generatedPlan.blockers[0]?.code).toBe("generated-project"); + expect(await treeDigest(generatedRoot)).toEqual(generatedBefore); + + const nestedRoot = await temporaryRoot(); + await mkdir(join(nestedRoot, "ios")); + await createIOSFixture(join(nestedRoot, "ios")); + await transformProject(join(nestedRoot, "ios"), removeClerkSDK); + await Bun.write(join(nestedRoot, "ios", "project.yml"), "name: MyApp\n"); + const nestedPlan = await planIOSSDKInstall({ + ...installOptions(nestedRoot), + projectPath: "ios/MyApp.xcodeproj", + }); + expect(nestedPlan.blockers[0]?.code).toBe("generated-project"); + + const outside = await temporaryRoot("clerk-ios-install-outside-"); + await createIOSFixture(outside); + const symlinkRoot = await temporaryRoot(); + await symlink(join(outside, "MyApp.xcodeproj"), join(symlinkRoot, "MyApp.xcodeproj")); + const escapedBefore = await treeDigest(symlinkRoot); + const escapedPlan = await planIOSSDKInstall(installOptions(symlinkRoot)); + expect(escapedPlan.blockers[0]?.code).toBe("external-path"); + expect(await treeDigest(symlinkRoot)).toEqual(escapedBefore); + + const leafRoot = await fixture(); + const leaf = pbxprojPath(leafRoot); + const realLeaf = join(leafRoot, "MyApp.xcodeproj", "actual.pbxproj"); + await rename(leaf, realLeaf); + await symlink("actual.pbxproj", leaf); + const leafBefore = await treeDigest(leafRoot); + const leafPlan = await planIOSSDKInstall(installOptions(leafRoot)); + expect(leafPlan.blockers[0]?.code).toBe("unreadable-project"); + expect(await treeDigest(leafRoot)).toEqual(leafBefore); + }); + + test("rejects a stale plan and preserves the newer bytes", async () => { + const root = await fixture(); + await transformProject(root, removeClerkSDK); + const plan = await planIOSSDKInstall(installOptions(root)); + expect(plan.status).toBe("ready"); + + await appendFile(pbxprojPath(root), "\n// newer user edit\n"); + const newerBytes = await readFile(pbxprojPath(root)); + const prepared = await prepareIOSSDKInstallMutation(plan); + expect(prepared.status).toBe("stale"); + expect("mutation" in prepared).toBe(false); + const result = await applyIOSSDKInstall(plan); + expect(result.status).toBe("stale"); + expect(await readFile(pbxprojPath(root))).toEqual(newerBytes); + expect( + (await readdir(join(root, "MyApp.xcodeproj"))).some((name) => name.includes(".clerk-")), + ).toBe(false); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts new file mode 100644 index 000000000..164987975 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -0,0 +1,1508 @@ +import { lstat, readFile } from "node:fs/promises"; +import { isDeepStrictEqual } from "node:util"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import semver from "semver"; +import { inspectIOSProject } from "./inspect.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSExistingFileTransaction, + hashIOSFileBytes, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; +import { + asString, + asStringArray, + isClerkIOSRepository, + isRecord, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; + +const APP_PRODUCT_TYPE = "com.apple.product-type.application"; +const CLERK_REPOSITORY = "https://github.com/clerk/clerk-ios"; +const MAX_PBXPROJ_BYTES = 15_000_000; +const MAX_PACKAGE_METADATA_BYTES = 2_000_000; +const PRODUCT_NAMES = ["ClerkKit", "ClerkKitUI"] as const; + +export const DEFAULT_CLERK_IOS_MINIMUM_VERSION = "1.0.0"; +// These floors are equal today, but remain separate so AuthView can raise its +// minimum without changing the core-only ClerkKit installation policy. +export const PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION = DEFAULT_CLERK_IOS_MINIMUM_VERSION; + +export type IOSSDKProduct = (typeof PRODUCT_NAMES)[number]; + +export interface IOSSDKInstallOptions { + root: string; + /** Project-root-relative path selected by the iOS inspector. */ + projectPath: string; + targetId: string; + includeClerkKitUI?: boolean; + /** Used only when a new clerk-ios remote reference must be created. */ + minimumVersion?: string; + /** Require proof that the selected package supports the documented ClerkKitUI products. */ + requirePrebuiltAuthCompatibility?: boolean; +} + +export type IOSSDKInstallBlockerCode = + | "invalid-selection" + | "external-path" + | "generated-project" + | "unreadable-project" + | "malformed-project" + | "target-not-found" + | "ambiguous-target" + | "ambiguous-package" + | "duplicate-package" + | "unattributed-product" + | "wrong-package" + | "duplicate-product" + | "ambiguous-frameworks-phase" + | "duplicate-build-file" + | "incompatible-sdk" + | "unsupported-project"; + +export interface IOSSDKInstallBlocker { + code: IOSSDKInstallBlockerCode; + message: string; +} + +export interface IOSSDKInstallPlan { + schemaVersion: 1; + kind: "clerk-ios-sdk-install"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + products: IOSSDKProduct[]; + minimumVersion: string; + requirePrebuiltAuthCompatibility?: true; + /** SHA-256 of the exact project.pbxproj bytes this plan was made from. */ + expectedPbxprojHash?: string; + actions: string[]; + blockers: IOSSDKInstallBlocker[]; +} + +export interface IOSSDKInstallApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSSDKInstallPlan; + message?: string; +} + +interface ProjectParts { + project: ReturnType; + objects: PbxObjects; + projectObjectId: string; + projectObject: PbxObject; + targetObject: PbxObject; +} + +interface VerifiedPackage { + id: string; + kind: "remote" | "local"; +} + +interface ProductGraph { + productId?: string; + inTarget: boolean; + buildFileId?: string; +} + +interface PreparedInstall { + plan: IOSSDKInstallPlan; + pbxprojPath?: string; + originalBytes?: Uint8Array; + originalHash?: string; + candidateBytes?: Uint8Array; + candidateHash?: string; + mode?: number; +} + +function requestedProducts(includeClerkKitUI: boolean | undefined): IOSSDKProduct[] { + return includeClerkKitUI ? ["ClerkKit", "ClerkKitUI"] : ["ClerkKit"]; +} + +function validMinimumVersion(value: string): boolean { + return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); +} + +function effectiveMinimumVersion(options: IOSSDKInstallOptions): string { + const requested = options.minimumVersion ?? DEFAULT_CLERK_IOS_MINIMUM_VERSION; + if ( + !options.requirePrebuiltAuthCompatibility || + semver.valid(requested) == null || + semver.gte(requested, PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION) + ) { + return requested; + } + return PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION; +} + +function supportedRemoteRequirement(value: unknown): boolean { + if (!isRecord(value)) return false; + const kind = asString(value.kind); + if (kind === "upToNextMajorVersion" || kind === "upToNextMinorVersion") { + const minimumVersion = asString(value.minimumVersion); + return minimumVersion != null && validMinimumVersion(minimumVersion); + } + if (kind === "versionRange") { + const minimumVersion = asString(value.minimumVersion); + const maximumVersion = asString(value.maximumVersion); + return ( + minimumVersion != null && + maximumVersion != null && + validMinimumVersion(minimumVersion) && + validMinimumVersion(maximumVersion) + ); + } + if (kind === "exactVersion") { + const version = asString(value.version); + return version != null && validMinimumVersion(version); + } + if (kind === "branch") return (asString(value.branch)?.trim().length ?? 0) > 0; + if (kind === "revision") return (asString(value.revision)?.trim().length ?? 0) > 0; + return false; +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [relativePath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, relativePath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +function makePlan( + options: IOSSDKInstallOptions, + root: string, + projectPath: string, + status: IOSSDKInstallPlan["status"], + details: { + actions?: string[]; + blockers?: IOSSDKInstallBlocker[]; + expectedPbxprojHash?: string; + } = {}, +): IOSSDKInstallPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-sdk-install", + status, + root, + projectPath, + targetId: options.targetId, + products: requestedProducts(options.includeClerkKitUI), + minimumVersion: effectiveMinimumVersion(options), + ...(options.requirePrebuiltAuthCompatibility ? { requirePrebuiltAuthCompatibility: true } : {}), + expectedPbxprojHash: details.expectedPbxprojHash, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSSDKInstallOptions, + root: string, + projectPath: string, + code: IOSSDKInstallBlockerCode, + message: string, + source: Partial = {}, +): PreparedInstall { + return { + ...source, + plan: makePlan(options, root, projectPath, "blocked", { + blockers: [{ code, message }], + }), + }; +} + +function strictStringArray(object: PbxObject, key: string): string[] | undefined { + const value = object[key]; + if (value == null) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + return undefined; + } + return [...value]; +} + +function projectParts( + project: ReturnType, + targetId: string, +): ProjectParts | undefined { + const archive: unknown = project; + if (!isRecord(archive) || !isRecord(archive.objects)) return undefined; + for (const object of Object.values(archive.objects)) { + if (!isRecord(object)) return undefined; + } + // Retain the parsed dictionary itself. Newly allocated object IDs must land + // in the model that the writer serializes, not a detached index copy. + const objects = archive.objects as PbxObjects; + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects[projectObjectId] : undefined; + const targetObject = objects[targetId]; + if (!projectObjectId || projectObject?.isa !== "PBXProject" || !targetObject) { + return undefined; + } + return { project, objects, projectObjectId, projectObject, targetObject }; +} + +function swiftManifestWithoutComments(source: string): string { + const chars = source.split(""); + const blank = (start: number, end: number) => { + for (let index = start; index < end; index += 1) { + if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " "; + } + }; + let index = 0; + while (index < chars.length) { + if (chars[index] === "/" && chars[index + 1] === "/") { + const start = index; + index += 2; + while (index < chars.length && chars[index] !== "\n") index += 1; + blank(start, index); + continue; + } + if (chars[index] === "/" && chars[index + 1] === "*") { + const start = index; + let depth = 1; + index += 2; + while (index < chars.length && depth > 0) { + if (chars[index] === "/" && chars[index + 1] === "*") { + depth += 1; + index += 2; + } else if (chars[index] === "*" && chars[index + 1] === "/") { + depth -= 1; + index += 2; + } else { + index += 1; + } + } + blank(start, index); + continue; + } + + let hashCount = 0; + while (chars[index + hashCount] === "#") hashCount += 1; + const quoteIndex = index + hashCount; + if (chars[quoteIndex] !== '"') { + index += 1; + continue; + } + const multiline = chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; + index = quoteIndex + (multiline ? 3 : 1); + while (index < chars.length) { + const closesQuote = multiline + ? chars[index] === '"' && chars[index + 1] === '"' && chars[index + 2] === '"' + : chars[index] === '"'; + if (closesQuote) { + const quoteLength = multiline ? 3 : 1; + let closesHashes = true; + for (let hash = 0; hash < hashCount; hash += 1) { + if (chars[index + quoteLength + hash] !== "#") closesHashes = false; + } + if (closesHashes) { + index += quoteLength + hashCount; + break; + } + } + if (chars[index] === "\\") { + let escapeHashes = 0; + while (chars[index + 1 + escapeHashes] === "#") escapeHashes += 1; + if (escapeHashes === hashCount) { + index += 2 + escapeHashes; + continue; + } + } + index += 1; + } + } + return chars.join(""); +} + +async function safeDirectory(root: string, path: string): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) return false; + try { + const info = await lstat(path); + return info.isDirectory() && !info.isSymbolicLink(); + } catch { + return false; + } +} + +async function localReferenceIsClerk( + root: string, + projectPath: string, + object: PbxObject, +): Promise { + const relativePath = asString(object.relativePath); + if (!relativePath) return false; + const packagePath = resolve(dirname(projectPath), relativePath); + 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 = swiftManifestWithoutComments(await manifest.text()); + const declaresClerkPackage = /\bPackage\s*\(\s*name\s*:\s*"Clerk"\s*,/s.test(source); + const declaresProduct = (name: IOSSDKProduct) => + new RegExp( + `\\.library\\s*\\(\\s*name\\s*:\\s*"${name}"\\s*,\\s*targets\\s*:\\s*\\[\\s*"${name}"\\s*\\]\\s*\\)`, + "s", + ).test(source); + return ( + declaresClerkPackage && + declaresProduct("ClerkKit") && + declaresProduct("ClerkKitUI") && + (await safeDirectory(root, resolve(packagePath, "Sources", "ClerkKit"))) && + (await safeDirectory(root, resolve(packagePath, "Sources", "ClerkKitUI"))) + ); + } catch { + return false; + } +} + +async function verifiedPackages( + root: string, + projectPath: string, + objects: PbxObjects, +): Promise { + const result: VerifiedPackage[] = []; + for (const [id, object] of Object.entries(objects)) { + if (object.isa === "XCRemoteSwiftPackageReference") { + const repository = asString(object.repositoryURL); + if (repository && isClerkIOSRepository(repository)) { + result.push({ id, kind: "remote" }); + } + } else if ( + object.isa === "XCLocalSwiftPackageReference" && + (await localReferenceIsClerk(root, projectPath, object)) + ) { + result.push({ id, kind: "local" }); + } + } + return result.sort((left, right) => left.id.localeCompare(right.id)); +} + +type RemoteRequirementProof = "compatible" | "incompatible" | "needs-resolution"; + +function requirementBounds(requirement: PbxObject): { + minimum?: string; + maximum?: string; + exact?: string; +} { + const kind = asString(requirement.kind); + if (kind === "exactVersion") return { exact: asString(requirement.version) }; + if (kind === "versionRange") { + return { + minimum: asString(requirement.minimumVersion), + maximum: asString(requirement.maximumVersion), + }; + } + if (kind === "upToNextMajorVersion" || kind === "upToNextMinorVersion") { + const minimum = asString(requirement.minimumVersion); + const parsed = minimum == null ? null : semver.parse(minimum); + if (!minimum || !parsed) return {}; + return { + minimum, + maximum: + kind === "upToNextMajorVersion" + ? `${parsed.major + 1}.0.0` + : `${parsed.major}.${parsed.minor + 1}.0`, + }; + } + return {}; +} + +function remoteRequirementProof( + requirement: PbxObject, + requiredVersion: string, +): RemoteRequirementProof { + const bounds = requirementBounds(requirement); + if (bounds.exact) { + return semver.valid(bounds.exact) && semver.gte(bounds.exact, requiredVersion) + ? "compatible" + : "incompatible"; + } + if (!bounds.minimum || semver.valid(bounds.minimum) == null) return "needs-resolution"; + if ( + bounds.maximum && + (semver.valid(bounds.maximum) == null || !semver.gt(bounds.maximum, bounds.minimum)) + ) { + return "incompatible"; + } + if (semver.gte(bounds.minimum, requiredVersion)) return "compatible"; + if ( + bounds.maximum && + semver.valid(bounds.maximum) != null && + !semver.lt(requiredVersion, bounds.maximum) + ) { + return "incompatible"; + } + return "needs-resolution"; +} + +function requirementAllowsVersion(requirement: PbxObject, version: string): boolean { + if (semver.valid(version) == null) return false; + const bounds = requirementBounds(requirement); + if (bounds.exact) return semver.valid(bounds.exact) != null && semver.eq(version, bounds.exact); + if (!bounds.minimum || semver.valid(bounds.minimum) == null) return false; + if (semver.lt(version, bounds.minimum)) return false; + return ( + !bounds.maximum || (semver.valid(bounds.maximum) != null && semver.lt(version, bounds.maximum)) + ); +} + +function packageResolvedPaths( + root: string, + projectPath: string, + inspection: Awaited>, +): string[] { + const paths = new Set([ + resolve( + root, + projectPath, + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ), + ]); + for (const workspace of inspection.workspaces) { + if (workspace.projectPaths.includes(projectPath)) { + paths.add(resolve(root, workspace.path, "xcshareddata", "swiftpm", "Package.resolved")); + } + } + return [...paths].sort(); +} + +async function resolvedClerkVersions( + root: string, + projectPath: string, + inspection: Awaited>, +): Promise<{ versions: string[]; unreadable: boolean }> { + const versions: string[] = []; + let unreadable = false; + for (const path of packageResolvedPaths(root, projectPath, inspection)) { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) { + unreadable = true; + continue; + } + let info: Awaited>; + try { + info = await lstat(path); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") continue; + unreadable = true; + continue; + } + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PACKAGE_METADATA_BYTES) { + unreadable = true; + continue; + } + try { + const document: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isRecord(document)) throw new Error("invalid Package.resolved root"); + const legacyObject = isRecord(document.object) ? document.object : undefined; + const pins = Array.isArray(document.pins) + ? document.pins + : Array.isArray(legacyObject?.pins) + ? legacyObject.pins + : undefined; + if (!pins) throw new Error("invalid Package.resolved pins"); + for (const pin of pins) { + if (!isRecord(pin)) { + unreadable = true; + continue; + } + const location = asString(pin.location) ?? asString(pin.repositoryURL); + const identity = (asString(pin.identity) ?? asString(pin.package))?.toLowerCase(); + const isClerkPin = location + ? isClerkIOSRepository(location) + : identity === "clerk-ios" || identity === "clerk"; + if (!isClerkPin) continue; + const state = isRecord(pin.state) ? pin.state : undefined; + const version = state ? asString(state.version) : undefined; + if (!version || semver.valid(version) == null) unreadable = true; + else versions.push(version); + } + } catch { + unreadable = true; + } + } + return { versions: [...new Set(versions)].sort(semver.compare), unreadable }; +} + +async function prebuiltAuthCompatibilityBlocker( + root: string, + projectPath: string, + inspection: Awaited>, + selectedPackage: VerifiedPackage, + objects: PbxObjects, +): Promise { + const requiredVersion = PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION; + const prefix = `ClerkKitUI's documented native components require clerk-ios ${requiredVersion} or newer.`; + if (selectedPackage.kind === "local") { + return { + code: "incompatible-sdk", + message: `${prefix} A local package's compiled target membership cannot be proven without executing its Package.swift manifest, so no source was changed. Use a compatible remote clerk-ios package or integrate AuthView manually.`, + }; + } + + const requirement = objects[selectedPackage.id]?.requirement; + if (!isRecord(requirement)) { + return { + code: "incompatible-sdk", + message: `${prefix} The existing remote package requirement could not prove that version, so no source was changed.`, + }; + } + const proof = remoteRequirementProof(requirement, requiredVersion); + if (proof === "compatible") return undefined; + if (proof === "incompatible") { + return { + code: "incompatible-sdk", + message: `${prefix} The existing remote package requirement excludes that version, so no source was changed. Update the package requirement and rerun clerk init.`, + }; + } + + const resolved = await resolvedClerkVersions(root, projectPath, inspection); + if ( + !resolved.unreadable && + resolved.versions.length > 0 && + resolved.versions.every( + (version) => + semver.gte(version, requiredVersion) && requirementAllowsVersion(requirement, version), + ) + ) { + return undefined; + } + return { + code: "incompatible-sdk", + message: `${prefix} Neither the existing remote requirement nor a canonical Package.resolved file proves a compatible version, so no source was changed. Require or resolve clerk-ios ${requiredVersion} or newer, then rerun clerk init.`, + }; +} + +function duplicateValue(values: string[]): string | undefined { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return undefined; +} + +function stableObjectId(objects: PbxObjects, seed: string): string { + for (let attempt = 0; attempt < 10_000; attempt += 1) { + const id = new Bun.CryptoHasher("sha256") + .update(`clerk-ios-sdk:${seed}:${attempt}`) + .digest("hex") + .slice(0, 24) + .toUpperCase(); + if (!objects[id]) return id; + } + throw new Error("Could not allocate a deterministic Xcode object ID."); +} + +function clerkProductName(object: PbxObject | undefined): IOSSDKProduct | undefined { + if (object?.isa !== "XCSwiftPackageProductDependency") return undefined; + const name = asString(object.productName); + return PRODUCT_NAMES.find((productName) => productName === name); +} + +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(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 }; + } + const recognized = filters.every((filter) => + /(?:maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)/i.test(filter), + ); + return { applies: false, recognized }; +} + +function validateProductPackage( + productId: string, + objects: PbxObjects, + verifiedPackageIds: Set, + unsafeLocalPackageIds: Set, +): IOSSDKInstallBlocker | undefined { + const product = objects[productId]; + const productName = clerkProductName(product); + if (!productName) { + return { + code: "malformed-project", + message: `The selected target contains an unreadable Swift package product dependency (${productId}).`, + }; + } + const packageId = asString(product?.package); + if (!packageId) { + return { + code: "unattributed-product", + message: `${productName} is not attributed to a Swift package reference, so it cannot be repaired automatically.`, + }; + } + if (!verifiedPackageIds.has(packageId)) { + if (unsafeLocalPackageIds.has(packageId)) { + return { + code: "external-path", + message: `${productName} points to a local package that cannot be verified safely inside the project root.`, + }; + } + return { + code: "wrong-package", + message: `${productName} points to a package other than a verified clerk-ios reference.`, + }; + } + return undefined; +} + +function scanProductGraph( + productName: IOSSDKProduct, + targetProductIds: string[], + frameworkFiles: string[], + objects: PbxObjects, + verifiedPackageIds: Set, + unsafeLocalPackageIds: Set, +): { graph?: ProductGraph; blocker?: IOSSDKInstallBlocker } { + const targetMatches = targetProductIds.filter( + (id) => clerkProductName(objects[id]) === productName, + ); + if (targetMatches.length > 1) { + return { + blocker: { + code: "duplicate-product", + message: `The selected target contains more than one ${productName} product dependency.`, + }, + }; + } + + const phaseMatches: Array<{ buildFileId: string; productId: string }> = []; + for (const buildFileId of frameworkFiles) { + const buildFile = objects[buildFileId]; + if (!buildFile || buildFile.isa !== "PBXBuildFile") { + return { + blocker: { + code: "malformed-project", + message: `The selected target's Frameworks phase contains a dangling build file (${buildFileId}).`, + }, + }; + } + const productId = asString(buildFile.productRef); + if (productId && clerkProductName(objects[productId]) === productName) { + const applicability = buildFileIOSApplicability(buildFile); + if (!applicability.recognized) { + return { + blocker: { + code: "unsupported-project", + message: `${productName} has an unrecognized platform filter in the selected target's Frameworks phase.`, + }, + }; + } + if (applicability.applies) phaseMatches.push({ buildFileId, productId }); + } + } + if (phaseMatches.length > 1) { + return { + blocker: { + code: "duplicate-build-file", + message: `The selected target links ${productName} more than once in its Frameworks phase.`, + }, + }; + } + + const targetProductId = targetMatches[0]; + const phaseMatch = phaseMatches[0]; + if (targetProductId && phaseMatch && targetProductId !== phaseMatch.productId) { + return { + blocker: { + code: "duplicate-product", + message: `The selected target declares and links different ${productName} dependencies.`, + }, + }; + } + const productId = targetProductId ?? phaseMatch?.productId; + if (productId) { + const blocker = validateProductPackage( + productId, + objects, + verifiedPackageIds, + unsafeLocalPackageIds, + ); + if (blocker) return { blocker }; + } + return { + graph: { + productId, + inTarget: targetProductId != null, + buildFileId: phaseMatch?.buildFileId, + }, + }; +} + +function validateCandidateGraph( + parts: ProjectParts, + packageId: string, + products: IOSSDKProduct[], +): boolean { + const packageReferences = strictStringArray(parts.projectObject, "packageReferences"); + const targetProducts = strictStringArray(parts.targetObject, "packageProductDependencies"); + const buildPhases = strictStringArray(parts.targetObject, "buildPhases"); + if (!packageReferences || !targetProducts || !buildPhases) return false; + if (packageReferences.filter((id) => id === packageId).length !== 1) return false; + + const frameworkPhaseIds = buildPhases.filter( + (id) => parts.objects[id]?.isa === "PBXFrameworksBuildPhase", + ); + if (frameworkPhaseIds.length !== 1) return false; + const frameworkFiles = strictStringArray(parts.objects[frameworkPhaseIds[0]!]!, "files"); + if (!frameworkFiles) return false; + + for (const productName of products) { + const productIds: string[] = targetProducts.filter( + (id) => clerkProductName(parts.objects[id]) === productName, + ); + const productId = productIds[0]; + if (productIds.length !== 1 || !productId) return false; + if (asString(parts.objects[productId]?.package) !== packageId) return false; + const linked = frameworkFiles.filter((buildFileId) => { + const buildFile = parts.objects[buildFileId]; + return ( + buildFile?.isa === "PBXBuildFile" && + asString(buildFile?.productRef) === productId && + buildFileIOSApplicability(buildFile).recognized && + buildFileIOSApplicability(buildFile).applies + ); + }); + if (linked.length !== 1) return false; + const allLinkedProducts = frameworkFiles.filter((buildFileId) => { + const buildFile = parts.objects[buildFileId]; + if (!buildFile || buildFile.isa !== "PBXBuildFile") return false; + const linkedProduct = parts.objects[asString(buildFile.productRef) ?? ""]; + return ( + clerkProductName(linkedProduct) === productName && + buildFileIOSApplicability(buildFile).applies + ); + }); + if (allLinkedProducts.length !== 1) return false; + } + return true; +} + +async function prepareInstall(options: IOSSDKInstallOptions): Promise { + const root = resolve(options.root); + const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); + const minimumVersion = effectiveMinimumVersion(options); + if ( + !options.targetId || + !suppliedProjectPath || + isAbsolute(options.projectPath) || + !suppliedProjectPath.endsWith(".xcodeproj") || + !validMinimumVersion(minimumVersion) + ) { + return blocked( + options, + root, + suppliedProjectPath, + "invalid-selection", + "A selected root-relative .xcodeproj, target object ID, and valid minimum version are required.", + ); + } + + const absoluteProjectPath = resolve(root, suppliedProjectPath); + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if ( + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || + !(await pathIsSafelyWithinIOSRoot(root, pbxprojPath)) + ) { + return blocked( + options, + root, + projectPath, + "external-path", + `${projectPath}/project.pbxproj resolves outside the project root.`, + { pbxprojPath }, + ); + } + + let info: Awaited>; + let originalBuffer: Buffer; + try { + info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) { + throw new Error("unsupported project file"); + } + originalBuffer = await readFile(pbxprojPath); + } catch { + return blocked( + options, + root, + projectPath, + "unreadable-project", + `${projectPath}/project.pbxproj is missing, too large, symlinked, or unreadable.`, + { pbxprojPath }, + ); + } + const originalBytes = new Uint8Array(originalBuffer); + const originalHash = hashIOSFileBytes(originalBytes); + const source = { + pbxprojPath, + originalBytes, + originalHash, + mode: info.mode & 0o7777, + }; + + let originalText: string; + let parsed: ReturnType; + try { + originalText = new TextDecoder("utf-8", { fatal: true }).decode(originalBuffer); + parsed = parsePbxProject(originalText); + } catch { + return blocked( + options, + root, + projectPath, + "malformed-project", + `${projectPath}/project.pbxproj could not be parsed safely.`, + source, + ); + } + const parsedParts = projectParts(parsed, options.targetId); + if (!parsedParts) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected target ${options.targetId} does not exist in ${projectPath}.`, + source, + ); + } + if ( + parsedParts.targetObject.isa !== "PBXNativeTarget" || + asString(parsedParts.targetObject.productType) !== APP_PRODUCT_TYPE + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected object ${options.targetId} is not an application target.`, + source, + ); + } + + const inspection = await inspectIOSProject(root, { target: options.targetId }); + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (inspection.selection.state === "ambiguous") { + return blocked( + options, + root, + projectPath, + "ambiguous-target", + `Target object ID ${options.targetId} is ambiguous in this project root.`, + source, + ); + } + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected target ${options.targetId} is not a verified iOS application target in ${projectPath}.`, + source, + ); + } + + // Parse a second model instead of structured-cloning. pbxproj data literals + // can be Buffers, which structuredClone turns into writer-incompatible + // Uint8Arrays under Bun. + let model: ReturnType; + try { + model = parsePbxProject(originalText); + } catch { + return blocked( + options, + root, + projectPath, + "malformed-project", + "The Xcode project could not be parsed into an isolated mutation model.", + source, + ); + } + const parts = projectParts(model, options.targetId); + if (!parts) { + return blocked( + options, + root, + projectPath, + "malformed-project", + "The Xcode project object graph could not be cloned safely.", + source, + ); + } + const projectPackageIds = strictStringArray(parts.projectObject, "packageReferences"); + const targetProductIds = strictStringArray(parts.targetObject, "packageProductDependencies"); + const targetBuildPhases = strictStringArray(parts.targetObject, "buildPhases"); + if (!projectPackageIds || !targetProductIds || !targetBuildPhases) { + return blocked( + options, + root, + projectPath, + "malformed-project", + "The selected target has malformed package or build-phase reference lists.", + source, + ); + } + if (duplicateValue(projectPackageIds)) { + return blocked( + options, + root, + projectPath, + "duplicate-package", + "The project packageReferences list contains a duplicate object ID.", + source, + ); + } + if (duplicateValue(targetProductIds)) { + return blocked( + options, + root, + projectPath, + "duplicate-product", + "The selected target packageProductDependencies list contains a duplicate object ID.", + source, + ); + } + if (duplicateValue(targetBuildPhases)) { + return blocked( + options, + root, + projectPath, + "ambiguous-frameworks-phase", + "The selected target buildPhases list contains a duplicate object ID.", + source, + ); + } + if ( + projectPackageIds.some( + (id) => + !parts.objects[id] || + !["XCRemoteSwiftPackageReference", "XCLocalSwiftPackageReference"].includes( + parts.objects[id]!.isa ?? "", + ), + ) || + targetProductIds.some((id) => parts.objects[id]?.isa !== "XCSwiftPackageProductDependency") || + targetBuildPhases.some( + (id) => + !parts.objects[id] || !(asString(parts.objects[id]!.isa) ?? "").endsWith("BuildPhase"), + ) + ) { + return blocked( + options, + root, + projectPath, + "malformed-project", + "The selected target contains a dangling or invalid package, product, or build-phase reference.", + source, + ); + } + + const packages = await verifiedPackages(root, absoluteProjectPath, parts.objects); + if (packages.length > 1) { + return blocked( + options, + root, + projectPath, + "ambiguous-package", + "More than one verified clerk-ios package reference exists in this Xcode project.", + source, + ); + } + const verifiedPackageIds = new Set(packages.map((item) => item.id)); + const unsafeLocalPackageIds = new Set(); + for (const [id, object] of Object.entries(parts.objects)) { + if (object.isa !== "XCLocalSwiftPackageReference") continue; + const relativePath = asString(object.relativePath); + if ( + !relativePath || + !(await pathIsSafelyWithinIOSRoot( + root, + resolve(dirname(absoluteProjectPath), relativePath, "Package.swift"), + )) + ) { + unsafeLocalPackageIds.add(id); + } + } + + const frameworkPhaseIds = targetBuildPhases.filter( + (id) => parts.objects[id]?.isa === "PBXFrameworksBuildPhase", + ); + if (frameworkPhaseIds.length > 1) { + return blocked( + options, + root, + projectPath, + "ambiguous-frameworks-phase", + "The selected target contains more than one Frameworks build phase.", + source, + ); + } + let frameworkPhaseId = frameworkPhaseIds[0]; + let frameworkFiles: string[] = []; + if (frameworkPhaseId) { + const files = strictStringArray(parts.objects[frameworkPhaseId]!, "files"); + if (!files) { + return blocked( + options, + root, + projectPath, + "malformed-project", + "The selected target's Frameworks build phase has a malformed files list.", + source, + ); + } + if (duplicateValue(files)) { + return blocked( + options, + root, + projectPath, + "duplicate-build-file", + "The selected target's Frameworks build phase contains a duplicate build file.", + source, + ); + } + frameworkFiles = files; + } + + const graphs = new Map(); + const productBlockers: IOSSDKInstallBlocker[] = []; + for (const productName of PRODUCT_NAMES) { + const result = scanProductGraph( + productName, + targetProductIds, + frameworkFiles, + parts.objects, + verifiedPackageIds, + unsafeLocalPackageIds, + ); + if (result.blocker) { + productBlockers.push(result.blocker); + } else { + graphs.set(productName, result.graph!); + } + } + if (productBlockers.length > 0) { + return { + ...source, + plan: makePlan(options, root, projectPath, "blocked", { + blockers: productBlockers, + }), + }; + } + + const actions: string[] = []; + let selectedPackage = packages[0]; + const packageWasPresent = selectedPackage != null; + if (!selectedPackage) { + const packageId = stableObjectId( + parts.objects, + `${parts.projectObjectId}:${options.targetId}:remote-package:${CLERK_REPOSITORY}`, + ); + parts.objects[packageId] = { + isa: "XCRemoteSwiftPackageReference", + repositoryURL: CLERK_REPOSITORY, + requirement: { kind: "upToNextMajorVersion", minimumVersion }, + }; + selectedPackage = { id: packageId, kind: "remote" }; + verifiedPackageIds.add(packageId); + actions.push(`Add clerk-ios ${minimumVersion} or newer as a Swift package reference.`); + } else if (selectedPackage.kind === "remote") { + const packageObject = parts.objects[selectedPackage.id]; + if (!supportedRemoteRequirement(packageObject?.requirement)) { + return blocked( + options, + root, + projectPath, + "unsupported-project", + "The existing clerk-ios remote reference has no readable package requirement.", + source, + ); + } + } + + if (options.requirePrebuiltAuthCompatibility && packageWasPresent) { + const compatibilityBlocker = await prebuiltAuthCompatibilityBlocker( + root, + projectPath, + inspection, + selectedPackage, + parts.objects, + ); + if (compatibilityBlocker) { + return { + ...source, + plan: makePlan(options, root, projectPath, "blocked", { + blockers: [compatibilityBlocker], + }), + }; + } + } + + if (!projectPackageIds.includes(selectedPackage.id)) { + parts.projectObject.packageReferences = [...projectPackageIds, selectedPackage.id]; + projectPackageIds.push(selectedPackage.id); + actions.push("Attach the verified clerk-ios package reference to the Xcode project."); + } + + const products = requestedProducts(options.includeClerkKitUI); + const requiresFrameworkPhase = products.some( + (productName) => !graphs.get(productName)?.buildFileId, + ); + if (!frameworkPhaseId && requiresFrameworkPhase) { + frameworkPhaseId = stableObjectId( + parts.objects, + `${parts.projectObjectId}:${options.targetId}:frameworks-phase`, + ); + parts.objects[frameworkPhaseId] = { + isa: "PBXFrameworksBuildPhase", + buildActionMask: 2147483647, + files: [], + runOnlyForDeploymentPostprocessing: 0, + }; + parts.targetObject.buildPhases = [...targetBuildPhases, frameworkPhaseId]; + frameworkFiles = []; + actions.push("Create a Frameworks build phase for the selected target."); + } + + for (const productName of products) { + const graph = graphs.get(productName)!; + let productId = graph.productId; + if (!productId) { + productId = stableObjectId( + parts.objects, + `${parts.projectObjectId}:${options.targetId}:product:${selectedPackage.id}:${productName}`, + ); + parts.objects[productId] = { + isa: "XCSwiftPackageProductDependency", + package: selectedPackage.id, + productName, + }; + } + if (!graph.inTarget) { + const currentProducts = strictStringArray(parts.targetObject, "packageProductDependencies")!; + parts.targetObject.packageProductDependencies = [...currentProducts, productId]; + actions.push(`Add ${productName} to the selected target's package products.`); + } + if (!graph.buildFileId) { + if (!frameworkPhaseId) { + return blocked( + options, + root, + projectPath, + "unsupported-project", + `A Frameworks phase could not be created for ${productName}.`, + source, + ); + } + const buildFileId = stableObjectId( + parts.objects, + `${parts.projectObjectId}:${options.targetId}:build-file:${productId}`, + ); + parts.objects[buildFileId] = { isa: "PBXBuildFile", productRef: productId }; + const phase = parts.objects[frameworkPhaseId]!; + const currentFiles = strictStringArray(phase, "files")!; + phase.files = [...currentFiles, buildFileId]; + actions.push(`Link ${productName} in the selected target's Frameworks phase.`); + } + } + + if (actions.length === 0) { + return { + ...source, + plan: makePlan(options, root, projectPath, "satisfied", { + expectedPbxprojHash: originalHash, + }), + }; + } + if (generator) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated project.pbxproj output.`, + source, + ); + } + + let candidate: string; + let reparsed: ReturnType; + try { + candidate = buildPbxProject(model); + reparsed = parsePbxProject(candidate); + } catch { + return blocked( + options, + root, + projectPath, + "unsupported-project", + "The proposed Xcode project could not be serialized and reparsed safely.", + source, + ); + } + if (!isDeepStrictEqual(reparsed, model)) { + return blocked( + options, + root, + projectPath, + "unsupported-project", + "Serializing this Xcode project would change unsupported object-graph data.", + source, + ); + } + const candidateParts = projectParts(reparsed, options.targetId); + if (!candidateParts || !validateCandidateGraph(candidateParts, selectedPackage.id, products)) { + return blocked( + options, + root, + projectPath, + "unsupported-project", + "The proposed Xcode project did not pass package-linkage validation.", + source, + ); + } + + const candidateBytes = new TextEncoder().encode(candidate); + return { + ...source, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + plan: makePlan(options, root, projectPath, "ready", { + actions, + expectedPbxprojHash: originalHash, + }), + }; +} + +/** @internal Postcondition for a combined PBX project and Swift source transaction. */ +export async function validateIOSSDKInstallPostcondition( + plan: IOSSDKInstallPlan, +): Promise { + const absoluteProjectPath = resolve(plan.root, plan.projectPath); + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(plan.root, pbxprojPath))) return false; + let parsed: ReturnType; + try { + parsed = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } + const parts = projectParts(parsed, plan.targetId); + if (!parts) return false; + const packages = await verifiedPackages(plan.root, absoluteProjectPath, parts.objects); + const selectedPackage = packages[0]; + if ( + packages.length !== 1 || + !selectedPackage || + !validateCandidateGraph(parts, selectedPackage.id, plan.products) + ) { + return false; + } + + const inspection = await inspectIOSProject(plan.root, { target: plan.targetId }); + if (inspection.generatedProject || (await generatedProjectKind(plan.root, absoluteProjectPath))) { + return false; + } + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== plan.targetId || + inspection.selection.projectPath !== plan.projectPath + ) { + return false; + } + if ( + plan.requirePrebuiltAuthCompatibility && + (await prebuiltAuthCompatibilityBlocker( + plan.root, + plan.projectPath, + inspection, + selectedPackage, + parts.objects, + )) != null + ) { + return false; + } + const target = inspection.appTargets.find( + (item) => item.id === plan.targetId && item.projectPath === plan.projectPath, + ); + if (!target || !["remote", "local"].includes(target.packages.package)) return false; + return plan.products.every((productName) => + productName === "ClerkKit" + ? target.packages.clerkKit === "linked" + : target.packages.clerkKitUI === "linked", + ); +} + +export async function planIOSSDKInstall(options: IOSSDKInstallOptions): Promise { + return (await prepareInstall(options)).plan; +} + +/** + * An internal SDK preparation result for a larger iOS file transaction. The + * ready case contains candidate PBX bytes and must not be logged or serialized. + * + * @internal + */ +export type PreparedIOSSDKInstallMutation = + | { status: "blocked"; plan: IOSSDKInstallPlan } + | { status: "stale"; plan: IOSSDKInstallPlan } + | { status: "satisfied"; plan: IOSSDKInstallPlan } + | { status: "ready"; plan: IOSSDKInstallPlan; mutation: IOSExistingFileMutation }; + +/** + * Reprepares a serialized SDK plan and exposes its PBX mutation without writing + * it so a caller can combine it with Swift source mutations. + * + * @internal The ready result contains candidate bytes. + */ +export async function prepareIOSSDKInstallMutation( + plan: IOSSDKInstallPlan, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + + const prepared = await prepareInstall({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + includeClerkKitUI: plan.products.includes("ClerkKitUI"), + minimumVersion: plan.minimumVersion, + requirePrebuiltAuthCompatibility: plan.requirePrebuiltAuthCompatibility, + }); + if (!plan.expectedPbxprojHash || prepared.originalHash !== plan.expectedPbxprojHash) { + return { status: "stale", plan }; + } + if (prepared.plan.status === "blocked") { + return { status: "blocked", plan: prepared.plan }; + } + if (prepared.plan.status === "satisfied") { + return { status: "satisfied", plan: prepared.plan }; + } + if ( + !prepared.pbxprojPath || + !prepared.originalBytes || + !prepared.candidateBytes || + !prepared.candidateHash || + prepared.mode == null + ) { + return { + status: "blocked", + plan: makePlan( + { + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + includeClerkKitUI: plan.products.includes("ClerkKitUI"), + minimumVersion: plan.minimumVersion, + requirePrebuiltAuthCompatibility: plan.requirePrebuiltAuthCompatibility, + }, + plan.root, + plan.projectPath, + "blocked", + { + blockers: [ + { + code: "unsupported-project", + message: "The prepared install did not contain a validated candidate project.", + }, + ], + }, + ), + }; + } + + return { + status: "ready", + plan: prepared.plan, + mutation: { + path: prepared.pbxprojPath, + originalBytes: prepared.originalBytes, + originalHash: prepared.originalHash, + candidateBytes: prepared.candidateBytes, + candidateHash: prepared.candidateHash, + mode: prepared.mode, + }, + }; +} + +export async function applyIOSSDKInstall( + plan: IOSSDKInstallPlan, +): Promise { + const prepared = await prepareIOSSDKInstallMutation(plan); + if (prepared.status === "stale") { + return { + status: "stale", + plan, + message: "The Xcode project changed after the install plan was created.", + }; + } + if (prepared.status === "blocked") { + return { status: "blocked", plan: prepared.plan }; + } + if (prepared.status === "satisfied") { + return { status: "satisfied", plan: prepared.plan }; + } + + const writeResult = await applyIOSExistingFileTransaction( + [prepared.mutation], + [async () => validateIOSSDKInstallPostcondition(prepared.plan)], + ); + if (writeResult.status === "stale") { + return { + status: "stale", + plan, + message: "The Xcode project changed while the install was being applied.", + }; + } + return writeResult.status === "applied" + ? { status: "applied", plan: prepared.plan } + : { + status: "rolled-back", + plan: prepared.plan, + message: + "The proposed Xcode change failed post-write validation and was restored byte-for-byte.", + }; +} diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts new file mode 100644 index 000000000..1df124209 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -0,0 +1,1053 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { appendFile, mkdir, mkdtemp, readdir, rename, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import plist from "@expo/plist"; +import { + applyIOSRuntimeKey, + planIOSRuntimeKey, + planIOSRuntimeKeyVerification, + type IOSRuntimeKeyBlockerCode, + verifyIOSRuntimeKey, +} from "./runtime-key.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; +const LOADER_FILE = "474747474747474747474747"; +const LOADER_BUILD_FILE = "484848484848484848484848"; +const TARGET_IGNORE_RULE = "/MyApp/LocalSecrets.plist\n"; +const TEMPORARY_IGNORE_RULE = "/MyApp/.LocalSecrets.plist.clerk-*.tmp\n"; + +function publishableKey(host: string, live = false): string { + return `pk_${live ? "live" : "test"}_${Buffer.from(`${host}$`).toString("base64")}`; +} + +function plistSource(key?: string): string { + return ` + + + + + ANALYTICS_ENABLED + +${key == null ? "" : ` CLERK_PUBLISHABLE_KEY\n ${key}\n`} + +`; +} + +const APP_SOURCE = `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") + } + + var body: some Scene { + WindowGroup { Text("Hello") } + .environment(Clerk.shared) + } +} +`; + +const LOADER_SOURCE = `import Foundation + +struct ClerkLocalSecrets { + let publishableKey: String? + + static func load( + bundle: Bundle = .main, + processInfo: ProcessInfo = .processInfo + ) -> ClerkLocalSecrets { + let plistValues = localSecretsPlistValues(bundle: bundle) + return .init( + publishableKey: resolveValue( + for: "CLERK_PUBLISHABLE_KEY", + processInfo: processInfo, + plistValues: plistValues + ) + ) + } + + private static func resolveValue( + for key: String, + processInfo: ProcessInfo, + plistValues: [String: Any] + ) -> String? { + if let environmentValue = normalized(processInfo.environment[key]) { + return environmentValue + } + return normalized(plistValues[key] as? String) + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private static func localSecretsPlistValues(bundle: Bundle) -> [String: Any] { + guard + let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), + let data = try? Data(contentsOf: url), + let propertyList = try? PropertyListSerialization.propertyList(from: data, format: nil), + let values = propertyList as? [String: Any] + else { + return [:] + } + return values + } +} +`; + +async function fixture(key?: string, secondTarget = false): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-key-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + localSecrets: true, + secondTarget, + }); + await Bun.write(join(root, "MyApp", "MyAppApp.swift"), APP_SOURCE); + await Bun.write(join(root, "MyApp", "ClerkLocalSecrets.swift"), LOADER_SOURCE); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), plistSource(key)); + + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replace( + `children = ( ${IOS_FIXTURE_IDS.appFile}, ${IOS_FIXTURE_IDS.localSecretsFile}, );`, + `children = ( ${IOS_FIXTURE_IDS.appFile}, ${LOADER_FILE}, ${IOS_FIXTURE_IDS.localSecretsFile}, );`, + ) + .replace( + `${IOS_FIXTURE_IDS.appFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; };`, + `${IOS_FIXTURE_IDS.appFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; };\n ${LOADER_FILE} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClerkLocalSecrets.swift; sourceTree = ""; };`, + ) + .replace( + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, );`, + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, ${LOADER_BUILD_FILE}, );`, + ) + .replace( + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IOS_FIXTURE_IDS.appFile}; };`, + `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IOS_FIXTURE_IDS.appFile}; };\n ${LOADER_BUILD_FILE} = { isa = PBXBuildFile; fileRef = ${LOADER_FILE}; };`, + ), + ); + return root; +} + +function options(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }; +} + +async function run(root: string, key: string) { + const plan = await planIOSRuntimeKey(options(root)); + const result = await applyIOSRuntimeKey(plan, key); + return { plan, result }; +} + +async function initGit(root: string): Promise { + const child = Bun.spawn(["git", "init", "--quiet"], { + cwd: root, + stdout: "ignore", + stderr: "pipe", + }); + if ((await child.exited) !== 0) { + throw new Error(await new Response(child.stderr).text()); + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS runtime publishable-key transaction", () => { + test("verifies an existing runtime key without retaining either compared value", async () => { + const localKey = publishableKey("verify-local.clerk.example"); + const linkedKey = publishableKey("verify-linked.clerk.example"); + const root = await fixture(localKey); + const plan = await planIOSRuntimeKeyVerification(options(root)); + + const matched = await verifyIOSRuntimeKey(plan, localKey); + const mismatched = await verifyIOSRuntimeKey(plan, linkedKey); + + expect(plan.status).toBe("ready"); + expect(matched.status).toBe("matched"); + expect(mismatched.status).toBe("mismatched"); + expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(localKey); + expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(linkedKey); + }); + + test("treats the same valid key in an ignored target sink as a byte-for-byte no-op", async () => { + const key = publishableKey("same.clerk.example"); + const root = await fixture(key); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); + const before = await treeDigest(root); + + const { plan, result } = await run(root, key); + + expect(plan.status).toBe("ready"); + expect(result.status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify({ plan, result })).not.toContain(key); + }); + + test("replaces an invalid placeholder while preserving unrelated XML bytes", async () => { + const root = await fixture("pk_test_..."); + const key = publishableKey("replacement.clerk.example"); + const path = join(root, "MyApp", "LocalSecrets.plist"); + const before = await Bun.file(path).text(); + + const { plan, result } = await run(root, key); + const after = await Bun.file(path).text(); + + expect(result.status).toBe("applied"); + expect(after).toBe(before.replace("pk_test_...", key)); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + expect(JSON.stringify({ plan, result })).not.toContain(key); + }); + + test("plans a gitignore change for crash-safe staging even when the target rule exists", async () => { + const root = await fixture("pk_test_..."); + await Bun.write(join(root, ".gitignore"), TARGET_IGNORE_RULE); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("ready"); + expect(plan.changesGitignore).toBe(true); + expect(plan.actions.some((action) => action.includes("atomic-write staging file"))).toBe(true); + }); + + test("inserts a missing key without changing unrelated plist values", async () => { + const root = await fixture(); + const key = publishableKey("insert.clerk.example"); + const path = join(root, "MyApp", "LocalSecrets.plist"); + + const { result } = await run(root, key); + const source = await Bun.file(path).text(); + const parsed = plist.parse(source) as Record; + + expect(result.status).toBe("applied"); + expect(parsed.ANALYTICS_ENABLED).toBe(true); + expect(parsed.CLERK_PUBLISHABLE_KEY).toBe(key); + expect(source).toContain(""); + }); + + test("does not insert a duplicate semantic key when its XML spelling is encoded", async () => { + const root = await fixture("pk_test_..."); + const path = join(root, "MyApp", "LocalSecrets.plist"); + await Bun.write( + path, + plistSource("pk_test_...").replace("CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"), + ); + const before = await treeDigest(root); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey(plan, publishableKey("encoded-key.clerk.example")); + + expect(result.status).toBe("blocked"); + expect(result.plan.blockers[0]?.code).toBe("unsupported-local-secrets"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks a different valid key without writing any file", async () => { + const existingKey = publishableKey("existing.clerk.example"); + const replacementKey = publishableKey("different.clerk.example"); + const root = await fixture(existingKey); + const before = await treeDigest(root); + + const { plan, result } = await run(root, replacementKey); + + expect(result.status).toBe("blocked"); + expect(result.plan.blockers[0]?.code).toBe("different-publishable-key"); + expect(await treeDigest(root)).toEqual(before); + const serialized = JSON.stringify({ plan, result }); + expect(serialized).not.toContain(existingKey); + expect(serialized).not.toContain(replacementKey); + }); + + test("blocks invalid apply input without exposing it", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const invalid = "pk_test_not-a-real-key"; + + const result = await applyIOSRuntimeKey(plan, invalid); + + expect(result.status).toBe("blocked"); + expect(result.plan.blockers[0]?.code).toBe("invalid-publishable-key"); + expect(JSON.stringify(result)).not.toContain(invalid); + }); + + test("blocks a production publishable key without exposing it", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const productionKey = publishableKey("production.clerk.example", true); + + const result = await applyIOSRuntimeKey(plan, productionKey); + + expect(result.status).toBe("blocked"); + expect(result.plan.blockers[0]?.code).toBe("production-publishable-key"); + expect(JSON.stringify(result)).not.toContain(productionKey); + }); + + test("adds only the ignore rule when an existing valid key is not ignored", async () => { + const key = publishableKey("ignore-only.clerk.example"); + const root = await fixture(key); + const plistBefore = await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text(); + + const { result } = await run(root, key); + + expect(result.status).toBe("applied"); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toBe(plistBefore); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe("/MyApp/LocalSecrets.plist\n"); + }); + + test("normalizes surrounding whitespace in an otherwise matching key", async () => { + const key = publishableKey("normalized.clerk.example"); + const root = await fixture(` ${key}\n`); + + const { result } = await run(root, key); + + expect(result.status).toBe("applied"); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toContain( + `${key}`, + ); + }); + + test("adds a portable exact rule even when a broader Git pattern already ignores the sink", async () => { + const root = await fixture("pk_test_..."); + await initGit(root); + await Bun.write(join(root, ".gitignore"), "**/LocalSecrets.plist\n"); + const key = publishableKey("broad-ignore.clerk.example"); + + const { result } = await run(root, key); + + expect(result.status).toBe("applied"); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + `**/LocalSecrets.plist\n${TEMPORARY_IGNORE_RULE}${TARGET_IGNORE_RULE}`, + ); + }); + + test("does not treat whitespace around a rule as the exact portable rule", async () => { + const root = await fixture("pk_test_..."); + await Bun.write(join(root, ".gitignore"), " /MyApp/LocalSecrets.plist\n"); + const key = publishableKey("whitespace-rule.clerk.example"); + + const { result } = await run(root, key); + + expect(result.status).toBe("applied"); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + ` /MyApp/LocalSecrets.plist\n${TEMPORARY_IGNORE_RULE}${TARGET_IGNORE_RULE}`, + ); + }); + + test("appends the exact rule after a later negation before reporting satisfaction", async () => { + for (const repository of [false, true]) { + const key = publishableKey(`${repository ? "git" : "plain"}-negated.clerk.example`); + const root = await fixture(key); + if (repository) await initGit(root); + await Bun.write( + join(root, ".gitignore"), + "/MyApp/LocalSecrets.plist\n!/MyApp/LocalSecrets.plist\n", + ); + + const { result } = await run(root, key); + + expect(result.status).toBe("applied"); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + "/MyApp/LocalSecrets.plist\n!/MyApp/LocalSecrets.plist\n/MyApp/LocalSecrets.plist\n", + ); + if (repository) { + const check = Bun.spawn( + ["git", "check-ignore", "--quiet", "--no-index", "--", "MyApp/LocalSecrets.plist"], + { cwd: root, stdout: "ignore", stderr: "ignore" }, + ); + expect(await check.exited).toBe(0); + } + } + }); + + test("blocks nested gitignore files that can override the root protection", async () => { + for (const repository of [false, true]) { + const root = await fixture("pk_test_..."); + if (repository) await initGit(root); + await Bun.write( + join(root, "MyApp", ".gitignore"), + "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", + ); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsafe-gitignore"); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("blocks a LocalSecrets.plist already tracked by Git", async () => { + const root = await fixture("pk_test_..."); + await initGit(root); + const add = Bun.spawn(["git", "add", "--", "MyApp/LocalSecrets.plist"], { + cwd: root, + stdout: "ignore", + stderr: "ignore", + }); + expect(await add.exited).toBe(0); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("tracked-local-secrets"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks every enabled selected-target Run-scheme override", async () => { + for (const schemeKey of [ + publishableKey("same-scheme.clerk.example"), + publishableKey("other-scheme.clerk.example"), + ]) { + const root = await fixture("pk_test_..."); + const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(directory, { recursive: true }); + await Bun.write( + join(directory, "MyApp.xcscheme"), + ``, + ); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("scheme-override"); + expect(JSON.stringify(plan)).not.toContain(schemeKey); + } + }); + + test("blocks malformed, binary, oversized, and symlinked sinks", async () => { + const cases: Array<{ + expected: IOSRuntimeKeyBlockerCode; + mutate(root: string): Promise; + }> = [ + { + expected: "malformed-local-secrets", + mutate: async (root) => { + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + }, + }, + { + expected: "malformed-local-secrets", + mutate: async (root) => { + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + new Uint8Array([0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30]), + ); + }, + }, + { + expected: "unreadable-local-secrets", + mutate: async (root) => { + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), "x".repeat(1_000_001)); + }, + }, + { + expected: "unreadable-local-secrets", + mutate: async (root) => { + const path = join(root, "MyApp", "LocalSecrets.plist"); + const outside = join(root, "outside.plist"); + await Bun.write(outside, plistSource("pk_test_...")); + await rm(path); + await symlink(outside, path); + }, + }, + ]; + + for (const item of cases) { + const root = await fixture("pk_test_..."); + await item.mutate(root); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe(item.expected); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("blocks a generated project and an explicitly selected non-target resource", async () => { + const generatedRoot = await fixture("pk_test_..."); + await Bun.write(join(generatedRoot, "project.yml"), "name: MyApp\n"); + const generatedPlan = await planIOSRuntimeKey(options(generatedRoot)); + expect(generatedPlan.blockers[0]?.code).toBe("generated-project"); + + const root = await fixture("pk_test_..."); + await mkdir(join(root, "NotTarget")); + await Bun.write(join(root, "NotTarget", "LocalSecrets.plist"), plistSource("pk_test_...")); + const plan = await planIOSRuntimeKey({ + ...options(root), + localSecretsPath: "NotTarget/LocalSecrets.plist", + }); + expect(plan.blockers[0]?.code).toBe("not-target-resource"); + }); + + test("blocks a generator marker beside a nested selected project", async () => { + const root = await fixture("pk_test_..."); + await mkdir(join(root, "ios")); + await rename(join(root, "MyApp.xcodeproj"), join(root, "ios", "MyApp.xcodeproj")); + await rename(join(root, "MyApp"), join(root, "ios", "MyApp")); + await Bun.write(join(root, "ios", "project.yml"), "name: MyApp\n"); + + const plan = await planIOSRuntimeKey({ + root, + projectPath: "ios/MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("generated-project"); + }); + + test("blocks an invocation root above the selected project's nested Git repository", async () => { + const root = await fixture("pk_test_..."); + const nested = join(root, "Nested"); + await mkdir(nested); + await rename(join(root, "MyApp.xcodeproj"), join(nested, "MyApp.xcodeproj")); + await rename(join(root, "MyApp"), join(nested, "MyApp")); + await initGit(nested); + + const plan = await planIOSRuntimeKey({ + root, + projectPath: "Nested/MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("git-repository-mismatch"); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + }); + + test("requires exact entrypoint, configure, loader, and sink proof", async () => { + const root = await fixture("pk_test_..."); + await Bun.write(join(root, "MyApp", "ClerkLocalSecrets.swift"), "import Foundation\n"); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); + }); + + test("does not treat a same-file unused configure helper as app-startup wiring", async () => { + const root = await fixture("pk_test_..."); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + APP_SOURCE.replace( + ` init() { + Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") + }`, + ` init() {} + + func unusedConfigureHelper() { + Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") + }`, + ), + ); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); + }); + + test("blocks a LocalSecrets resource shared by another iOS application target", async () => { + const root = await fixture("pk_test_...", true); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project.replace( + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, + ), + ); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("allows a sibling target's proven-disjoint external synchronized group", async () => { + const root = await fixture("pk_test_...", true); + const externalGroup = await mkdtemp(join(tmpdir(), "clerk-ios-external-group-")); + temporaryDirectories.push(externalGroup); + await Bun.write(join(externalGroup, "ExternalApp.swift"), "import SwiftUI\n"); + + const synchronizedGroupId = "515151515151515151515151"; + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replace( + `productType = "com.apple.product-type.application";\n packageProductDependencies = ( );`, + `productType = "com.apple.product-type.application";\n fileSystemSynchronizedGroups = ( ${synchronizedGroupId}, );\n packageProductDependencies = ( );`, + ) + .replace( + `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + `${synchronizedGroupId} = { isa = PBXFileSystemSynchronizedRootGroup; path = "${externalGroup}"; sourceTree = ""; };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + ), + ); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("ready"); + expect(plan.blockers).toEqual([]); + }); + + test("ignores a missing unrelated project while proving selected-project ownership", async () => { + const root = await fixture("pk_test_..."); + await mkdir(join(root, "Unrelated.xcodeproj")); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("ready"); + expect(plan.blockers).toEqual([]); + }); + + test("blocks an external symlinked resource that aliases the selected sink", async () => { + const root = await fixture("pk_test_...", true); + const externalGroup = await mkdtemp(join(tmpdir(), "clerk-ios-external-alias-")); + temporaryDirectories.push(externalGroup); + const externalAlias = join(externalGroup, "LocalSecrets.plist"); + await symlink(join(root, "MyApp", "LocalSecrets.plist"), externalAlias); + + const externalReferenceId = "525252525252525252525252"; + const externalBuildFileId = "535353535353535353535353"; + const externalResourcesPhaseId = "545454545454545454545454"; + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replace( + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${externalResourcesPhaseId}, );`, + ) + .replace( + `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + `${externalReferenceId} = { isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "${externalAlias}"; sourceTree = ""; };\n ${externalBuildFileId} = { isa = PBXBuildFile; fileRef = ${externalReferenceId}; };\n ${externalResourcesPhaseId} = { isa = PBXResourcesBuildPhase; files = ( ${externalBuildFileId}, ); };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + ), + ); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("fails closed when selected-project resource membership is dangling", async () => { + const root = await fixture("pk_test_...", true); + const danglingResourcesPhaseId = "555555555555555555555555"; + const danglingBuildFileId = "565656565656565656565656"; + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replace( + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${danglingResourcesPhaseId}, );`, + ) + .replace( + `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + `${danglingResourcesPhaseId} = { isa = PBXResourcesBuildPhase; files = ( ${danglingBuildFileId}, ); };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, + ), + ); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("rejects stale plist and gitignore plans without overwriting newer bytes", async () => { + const plistRoot = await fixture("pk_test_..."); + const plistPlan = await planIOSRuntimeKey(options(plistRoot)); + const plistPath = join(plistRoot, "MyApp", "LocalSecrets.plist"); + await appendFile(plistPath, "\n\n"); + const newerPlist = await Bun.file(plistPath).text(); + + const plistResult = await applyIOSRuntimeKey( + plistPlan, + publishableKey("stale-plist.clerk.example"), + ); + expect(plistResult.status).toBe("stale"); + expect(await Bun.file(plistPath).text()).toBe(newerPlist); + + const ignoreRoot = await fixture("pk_test_..."); + await Bun.write(join(ignoreRoot, ".gitignore"), "build/\n"); + const ignorePlan = await planIOSRuntimeKey(options(ignoreRoot)); + await appendFile(join(ignoreRoot, ".gitignore"), "DerivedData/\n"); + const newerIgnore = await Bun.file(join(ignoreRoot, ".gitignore")).text(); + + const ignoreResult = await applyIOSRuntimeKey( + ignorePlan, + publishableKey("stale-ignore.clerk.example"), + ); + expect(ignoreResult.status).toBe("stale"); + expect(await Bun.file(join(ignoreRoot, ".gitignore")).text()).toBe(newerIgnore); + }); + + test("rolls back every committed file byte-for-byte after validation failure", async () => { + const root = await fixture("pk_test_..."); + const before = await treeDigest(root); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey(plan, publishableKey("rollback.clerk.example"), { + forcePostWriteValidationFailure: true, + }); + + expect(result.status).toBe("rolled-back"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("rolls back when concurrent Swift edits invalidate the proven runtime wiring", async () => { + const root = await fixture("pk_test_..."); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const plistBefore = await Bun.file(plistPath).text(); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey( + plan, + publishableKey("concurrent-swift.clerk.example"), + { + beforePostWriteValidation: async () => { + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + APP_SOURCE.replace("Clerk.configure", "Clerk.notConfigure"), + ); + }, + }, + ); + + expect(result.status).toBe("rolled-back"); + expect(await Bun.file(plistPath).text()).toBe(plistBefore); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + }); + + test("rolls back when a nested gitignore appears before post-write validation", async () => { + const root = await fixture("pk_test_..."); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const plistBefore = await Bun.file(plistPath).text(); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey( + plan, + publishableKey("concurrent-nested-ignore.clerk.example"), + { + beforePostWriteValidation: async () => { + await Bun.write( + join(root, "MyApp", ".gitignore"), + "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", + ); + }, + }, + ); + + expect(result.status).toBe("rolled-back"); + expect(await Bun.file(plistPath).text()).toBe(plistBefore); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + expect(await Bun.file(join(root, "MyApp", ".gitignore")).text()).toContain( + "!LocalSecrets.plist", + ); + }); + + test("rolls back when a sibling target concurrently begins owning the runtime sink", async () => { + const root = await fixture("pk_test_...", true); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const plistBefore = await Bun.file(plistPath).text(); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey( + plan, + publishableKey("concurrent-owner.clerk.example"), + { + beforePostWriteValidation: async () => { + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project.replace( + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, + ), + ); + }, + }, + ); + + expect(result.status).toBe("rolled-back"); + expect(await Bun.file(plistPath).text()).toBe(plistBefore); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + }); + + test("cleans every temporary file when plist staging fails after creation", async () => { + const root = await fixture("pk_test_..."); + const before = await treeDigest(root); + const plan = await planIOSRuntimeKey(options(root)); + + const result = await applyIOSRuntimeKey(plan, publishableKey("stage-fail.clerk.example"), { + forcePlistStageFailureAfterCreate: true, + }); + + expect(result.status).toBe("rolled-back"); + expect(await treeDigest(root)).toEqual(before); + for (const directory of [root, join(root, "MyApp")]) { + expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); + } + }); + + test("retains the ignore guard when a staged key temp cannot be cleaned before rollback", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const key = publishableKey("stale-temp-cleanup.clerk.example"); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + + const apply = applyIOSRuntimeKey(plan, key, { + forcePlistCleanupFailureBeforeCommit: true, + afterPlistStage: async () => { + await appendFile(plistPath, "\n\n"); + }, + }); + + await expect(apply).rejects.toThrow("temporary runtime-key file could not be removed"); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + expect(await Bun.file(plistPath).text()).not.toContain(key); + for (const directory of [root, join(root, "MyApp")]) { + expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); + } + }); + + test("commits and verifies the temporary-file guard before writing key bytes", async () => { + const root = await fixture("pk_test_..."); + await initGit(root); + const plan = await planIOSRuntimeKey(options(root)); + let guardObserved = false; + + const result = await applyIOSRuntimeKey(plan, publishableKey("guard-first.clerk.example"), { + beforePlistWrite: async (temporaryPath) => { + expect(await Bun.file(temporaryPath).text()).toBe(""); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + const check = Bun.spawn( + ["git", "check-ignore", "--quiet", "--no-index", "--", relative(root, temporaryPath)], + { cwd: root, stdout: "ignore", stderr: "ignore" }, + ); + expect(await check.exited).toBe(0); + guardObserved = true; + }, + }); + + expect(result.status).toBe("applied"); + expect(guardObserved).toBe(true); + }); + + test("never writes key bytes when the committed guard is negated before plist staging", async () => { + const root = await fixture("pk_test_..."); + const key = publishableKey("guard-negated-before-write.clerk.example"); + const plan = await planIOSRuntimeKey(options(root)); + const result = await applyIOSRuntimeKey(plan, key, { + beforePlistWrite: async (temporaryPath) => { + const relativeTemporaryPath = relative(root, temporaryPath).split("\\").join("/"); + await appendFile( + join(root, ".gitignore"), + `!/${relativeTemporaryPath}\n!/MyApp/LocalSecrets.plist\n`, + ); + }, + }); + + expect(["stale", "rolled-back"]).toContain(result.status); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); + for (const name of await readdir(join(root, "MyApp"))) { + if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { + expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); + } + } + }); + + test("rolls back when the committed guard is negated after plist staging", async () => { + const root = await fixture("pk_test_..."); + const key = publishableKey("guard-negated-after-stage.clerk.example"); + const plan = await planIOSRuntimeKey(options(root)); + const result = await applyIOSRuntimeKey(plan, key, { + afterPlistStage: async () => { + const temporaryName = (await readdir(join(root, "MyApp"))).find((name) => + name.includes(".clerk-"), + ); + expect(temporaryName).toBeDefined(); + await appendFile( + join(root, ".gitignore"), + `!/MyApp/${temporaryName}\n!/MyApp/LocalSecrets.plist\n`, + ); + }, + }); + + expect(["stale", "rolled-back"]).toContain(result.status); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); + for (const name of await readdir(join(root, "MyApp"))) { + if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { + expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); + } + } + }); + + test("rolls back when the committed guard is negated after plist commit", async () => { + const root = await fixture("pk_test_..."); + const key = publishableKey("guard-negated-after-commit.clerk.example"); + const plan = await planIOSRuntimeKey(options(root)); + const result = await applyIOSRuntimeKey(plan, key, { + afterPlistCommit: async () => { + await appendFile( + join(root, ".gitignore"), + "!/MyApp/.LocalSecrets.plist.clerk-*.tmp\n!/MyApp/LocalSecrets.plist\n", + ); + }, + }); + + expect(["stale", "rolled-back"]).toContain(result.status); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); + for (const name of await readdir(join(root, "MyApp"))) { + if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { + expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); + } + } + }); + + test("rolls back a linked target when its staged temporary cleanup fails", async () => { + const root = await fixture("pk_test_..."); + const before = await treeDigest(root); + const plan = await planIOSRuntimeKey(options(root)); + + const apply = applyIOSRuntimeKey(plan, publishableKey("commit-cleanup.clerk.example"), { + forceGitignoreCommitCleanupFailure: true, + }); + + await expect(apply).rejects.toThrow("temporary runtime-key file could not be removed"); + expect(await treeDigest(root)).toEqual(before); + for (const directory of [root, join(root, "MyApp")]) { + expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); + } + }); + + test("retains the exact ignore rule when a newer key-bearing plist prevents rollback", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const key = publishableKey("partial-rollback.clerk.example"); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + + const apply = applyIOSRuntimeKey(plan, key, { + forcePostWriteValidationFailure: true, + beforePostWriteValidation: async () => { + await appendFile(plistPath, "\n\n"); + }, + }); + + await expect(apply).rejects.toThrow("Git-ignore protection was retained"); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + expect(await Bun.file(plistPath).text()).toContain("concurrent user edit"); + expect(await Bun.file(plistPath).text()).toContain(key); + }); + + test("re-establishes ignore protection when concurrent edits prevent payload rollback", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const key = publishableKey("protected-partial-rollback.clerk.example"); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + + const apply = applyIOSRuntimeKey(plan, key, { + afterPlistCommit: async () => { + await appendFile(plistPath, "\n\n"); + await appendFile( + join(root, ".gitignore"), + "!/MyApp/.LocalSecrets.plist.clerk-*.tmp\n!/MyApp/LocalSecrets.plist\n", + ); + }, + }); + + await expect(apply).rejects.toThrow("Git-ignore protection was retained"); + const gitignore = await Bun.file(join(root, ".gitignore")).text(); + expect(gitignore.endsWith(TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE)).toBe(true); + expect(gitignore.lastIndexOf("!/MyApp/LocalSecrets.plist")).toBeLessThan( + gitignore.lastIndexOf("/MyApp/LocalSecrets.plist"), + ); + expect(await Bun.file(plistPath).text()).toContain("concurrent user edit"); + expect(await Bun.file(plistPath).text()).toContain(key); + }); + + test("is idempotent after apply and removes every temporary file", async () => { + const root = await fixture("pk_test_..."); + const key = publishableKey("idempotent.clerk.example"); + + const first = await run(root, key); + expect(first.result.status).toBe("applied"); + const afterFirst = await treeDigest(root); + + const second = await run(root, key); + expect(second.result.status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(afterFirst); + for (const directory of [root, join(root, "MyApp")]) { + expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); + } + }); + + test("blocks a symlinked .gitignore without touching either target", async () => { + const root = await fixture("pk_test_..."); + const external = join(root, "external-ignore"); + await Bun.write(external, "build/\n"); + await symlink(external, join(root, ".gitignore")); + const before = await treeDigest(root); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsafe-gitignore"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks a LocalSecrets symlink after a path swap", async () => { + const root = await fixture("pk_test_..."); + const path = join(root, "MyApp", "LocalSecrets.plist"); + const original = join(root, "MyApp", "OriginalLocalSecrets.plist"); + await rename(path, original); + await symlink("OriginalLocalSecrets.plist", path); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unreadable-local-secrets"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts new file mode 100644 index 000000000..7523a5385 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -0,0 +1,2753 @@ +import { + chmod, + link, + lstat, + open, + readFile, + readdir, + realpath, + rename, + rm, +} from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import type { IOSAppTarget } from "./types.ts"; +import { + asString, + asStringArray, + buildPbxParentIndex, + isRecord, + resolvePbxFilePath, + type PbxObject, + type PbxObjects, +} from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; + +const APP_PRODUCT_TYPE = "com.apple.product-type.application"; +const MAX_PBXPROJ_BYTES = 15_000_000; +const MAX_LOCAL_SECRETS_BYTES = 1_000_000; +const MAX_GITIGNORE_BYTES = 1_000_000; +const MAX_DISCOVERY_DEPTH = 24; +const MAX_DISCOVERED_SECRETS = 20; +const MAX_OWNERSHIP_SCAN_ENTRIES = 20_000; +const SECRET_KEY = "CLERK_PUBLISHABLE_KEY"; +const DISCOVERY_IGNORES = new Set([ + ".build", + ".git", + ".swiftpm", + "build", + "Carthage", + "DerivedData", + "node_modules", + "Pods", + "SourcePackages", +]); + +export interface IOSRuntimeKeyPlanOptions { + root: string; + /** Project-root-relative path selected by the iOS inspector. */ + projectPath: string; + targetId: string; + /** Optional project-root-relative disambiguation when the target owns more than one sink. */ + localSecretsPath?: string; +} + +export type IOSRuntimeKeyBlockerCode = + | "invalid-selection" + | "external-path" + | "unreadable-project" + | "malformed-project" + | "target-not-found" + | "generated-project" + | "missing-local-secrets" + | "ambiguous-local-secrets" + | "not-target-resource" + | "shared-local-secrets" + | "unreadable-local-secrets" + | "malformed-local-secrets" + | "unsupported-local-secrets" + | "unproven-runtime-wiring" + | "scheme-override" + | "tracked-local-secrets" + | "git-state-unknown" + | "git-repository-mismatch" + | "unsafe-gitignore" + | "invalid-publishable-key" + | "production-publishable-key" + | "different-publishable-key"; + +export interface IOSRuntimeKeyBlocker { + code: IOSRuntimeKeyBlockerCode; + message: string; +} + +/** + * A structural, serializable plan. It intentionally contains neither the + * publishable key nor candidate plist bytes. The raw key is accepted only by + * applyIOSRuntimeKey. + */ +export interface IOSRuntimeKeyPlan { + schemaVersion: 1; + kind: "clerk-ios-runtime-key"; + status: "ready" | "blocked"; + root: string; + projectPath: string; + targetId: string; + localSecretsPath?: string; + gitignorePath?: string; + gitignoreRule?: string; + /** SHA-256 of the exact existing sink bytes inspected by this plan. */ + expectedLocalSecretsHash?: string; + /** Null means the .gitignore did not exist when the plan was created. */ + expectedGitignoreHash?: string | null; + /** True when apply may update .gitignore, including its crash-safe staging guard. */ + changesGitignore: boolean; + actions: string[]; + blockers: IOSRuntimeKeyBlocker[]; +} + +export interface IOSRuntimeKeyApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSRuntimeKeyPlan; + message?: string; +} + +/** + * A read-only, serializable proof of which runtime sink should be compared + * after Clerk application linking. It never contains the locally stored key. + */ +export interface IOSRuntimeKeyVerificationPlan { + schemaVersion: 1; + kind: "clerk-ios-runtime-key-verification"; + status: "ready" | "blocked"; + root: string; + projectPath: string; + targetId: string; + localSecretsPath?: string; + expectedLocalSecretsHash?: string; + blockers: IOSRuntimeKeyBlocker[]; +} + +export interface IOSRuntimeKeyVerificationResult { + status: "matched" | "mismatched" | "stale" | "blocked"; + plan: IOSRuntimeKeyVerificationPlan; +} + +/** @internal Test-only fault injection used to prove rollback. */ +export interface IOSRuntimeKeyApplyOptions { + forcePostWriteValidationFailure?: boolean; + forcePlistStageFailureAfterCreate?: boolean; + forcePlistCleanupFailureBeforeCommit?: boolean; + forceGitignoreCommitCleanupFailure?: boolean; + beforePlistWrite?: (temporaryPath: string) => void | Promise; + afterPlistStage?: () => void | Promise; + afterPlistCommit?: () => void | Promise; + beforePostWriteValidation?: () => void | Promise; +} + +type GitContext = + | { state: "repository"; root: string } + | { state: "not-repository" } + | { state: "unknown" } + | { state: "mismatch" }; + +interface FileSnapshot { + path: string; + exists: boolean; + hash?: string; + mode: number; + bytes?: Uint8Array; +} + +interface PreparedRuntimeKeyPlan { + plan: IOSRuntimeKeyPlan; + plist?: Record; + localSecretsSnapshot?: FileSnapshot; + gitignoreSnapshot?: FileSnapshot; + gitContext?: GitContext; + gitignoreNeeded?: boolean; +} + +interface PreparedRuntimeKeyVerification { + plan: IOSRuntimeKeyVerificationPlan; + localSecretsSnapshot?: FileSnapshot; + /** Kept only inside the verification call and never copied into a public result. */ + existingPublishableKey?: string; +} + +interface StagedFile { + targetPath: string; + temporaryPath: string; + candidateHash: string; + original: FileSnapshot; + committed: boolean; + cleanupFailuresRemaining: number; + keyBearing: boolean; +} + +interface RollbackDependency { + root: string; + /** The key-bearing file that must be made safe before its protection can be removed. */ + payloadPath: string; + /** The ignore file whose committed candidate protects the payload. */ + protectionPath: string; + /** Rules that protect both the final payload and its crash-safe staging file. */ + protectionRules: string[]; +} + +class RuntimeKeyTemporaryFileCleanupError extends Error { + constructor( + message: string, + readonly keyBearing: boolean, + ) { + super(message); + } +} + +interface StageFileOptions { + forceFailureAfterCreate?: boolean; + cleanupFailures?: number; + keyBearing?: boolean; + beforeWrite?: (temporaryPath: string) => boolean | Promise; +} + +function sha256(value: string | Uint8Array): string { + return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +} + +function makePlan( + options: IOSRuntimeKeyPlanOptions, + root: string, + projectPath: string, + status: IOSRuntimeKeyPlan["status"], + details: Partial< + Pick< + IOSRuntimeKeyPlan, + | "localSecretsPath" + | "gitignorePath" + | "gitignoreRule" + | "expectedLocalSecretsHash" + | "expectedGitignoreHash" + | "changesGitignore" + | "actions" + | "blockers" + > + > = {}, +): IOSRuntimeKeyPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-runtime-key", + status, + root, + projectPath, + targetId: options.targetId, + localSecretsPath: details.localSecretsPath, + gitignorePath: details.gitignorePath, + gitignoreRule: details.gitignoreRule, + expectedLocalSecretsHash: details.expectedLocalSecretsHash, + expectedGitignoreHash: details.expectedGitignoreHash, + changesGitignore: details.changesGitignore ?? false, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSRuntimeKeyPlanOptions, + root: string, + projectPath: string, + code: IOSRuntimeKeyBlockerCode, + message: string, + source: Partial = {}, +): PreparedRuntimeKeyPlan { + return { + ...source, + plan: makePlan(options, root, projectPath, "blocked", { + localSecretsPath: source.plan?.localSecretsPath, + gitignorePath: source.plan?.gitignorePath, + gitignoreRule: source.plan?.gitignoreRule, + expectedLocalSecretsHash: source.plan?.expectedLocalSecretsHash, + expectedGitignoreHash: source.plan?.expectedGitignoreHash, + changesGitignore: source.plan?.changesGitignore, + blockers: [{ code, message }], + }), + }; +} + +function makeVerificationPlan( + options: IOSRuntimeKeyPlanOptions, + root: string, + projectPath: string, + status: IOSRuntimeKeyVerificationPlan["status"], + details: Partial< + Pick< + IOSRuntimeKeyVerificationPlan, + "localSecretsPath" | "expectedLocalSecretsHash" | "blockers" + > + > = {}, +): IOSRuntimeKeyVerificationPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-runtime-key-verification", + status, + root, + projectPath, + targetId: options.targetId, + localSecretsPath: details.localSecretsPath, + expectedLocalSecretsHash: details.expectedLocalSecretsHash, + blockers: details.blockers ?? [], + }; +} + +function verificationBlocked( + options: IOSRuntimeKeyPlanOptions, + root: string, + projectPath: string, + code: IOSRuntimeKeyBlockerCode, + message: string, + source: Partial = {}, +): PreparedRuntimeKeyVerification { + return { + plan: makeVerificationPlan(options, root, projectPath, "blocked", { + localSecretsPath: source.plan?.localSecretsPath, + expectedLocalSecretsHash: source.plan?.expectedLocalSecretsHash, + blockers: [{ code, message }], + }), + }; +} + +function normalizedObjects(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; +} + +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 }; +} + +function normalizeSynchronizedPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); +} + +function containsControlCharacter(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function synchronizedExclusions( + group: PbxObject, + targetId: string, + resourcePhaseIds: 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" && + resourcePhaseIds.has(asString(exception.buildPhase) ?? ""); + if (!appliesToTarget && !appliesToPhase) continue; + + for (const path of asStringArray(exception.membershipExceptions)) { + excluded.add(normalizeSynchronizedPath(path)); + } + if (!isRecord(exception.platformFiltersByRelativePath)) continue; + 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 collectLocalSecrets( + root: string, + directory: string, + output: string[], + depth = 0, +): Promise { + if (depth > MAX_DISCOVERY_DEPTH || output.length >= MAX_DISCOVERED_SECRETS) return; + if (!(await pathIsSafelyWithinIOSRoot(root, directory))) return; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (output.length >= MAX_DISCOVERED_SECRETS) return; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (!entry.name.startsWith(".") && !DISCOVERY_IGNORES.has(entry.name)) { + await collectLocalSecrets(root, path, output, depth + 1); + } + } else if (entry.isFile() && entry.name === "LocalSecrets.plist") { + output.push(path); + } + } +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [relativePath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, relativePath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +async function targetLocalSecretsPaths( + root: string, + absoluteProjectPath: string, + targetId: string, +): Promise<{ paths?: string[]; blocker?: IOSRuntimeKeyBlocker }> { + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) { + return { + blocker: { + code: "external-path", + message: "The selected Xcode project resolves outside the project root.", + }, + }; + } + + let info; + let archive: unknown; + try { + info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) { + throw new Error("unsupported project file"); + } + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return { + blocker: { + code: "unreadable-project", + message: "The selected Xcode project is missing, too large, symlinked, or unreadable.", + }, + }; + } + if (!isRecord(archive)) { + return { + blocker: { + code: "malformed-project", + message: "The selected Xcode project has no readable object graph.", + }, + }; + } + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + const targetObject = objects?.[targetId]; + if (!objects || projectObject?.isa !== "PBXProject") { + return { + blocker: { + code: "malformed-project", + message: "The selected Xcode project has no readable PBXProject root.", + }, + }; + } + if ( + targetObject?.isa !== "PBXNativeTarget" || + asString(targetObject.productType) !== APP_PRODUCT_TYPE + ) { + return { + blocker: { + code: "target-not-found", + message: "The selected object is not an iOS application target.", + }, + }; + } + + const parents = buildPbxParentIndex(objects); + const projectDirectory = dirname(absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); + const resourcePhaseIds = new Set( + asStringArray(targetObject.buildPhases).filter( + (phaseId) => objects[phaseId]?.isa === "PBXResourcesBuildPhase", + ), + ); + const paths = new Set(); + + 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 fileReferenceId = asString(buildFile.fileRef); + if (!fileReferenceId) continue; + const path = resolvePbxFilePath( + fileReferenceId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if ( + path?.endsWith(`${sep}LocalSecrets.plist`) && + (await pathIsSafelyWithinIOSRoot(root, path)) + ) { + paths.add(path); + } + } + } + + 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 collectLocalSecrets(root, groupPath, discovered); + const excluded = synchronizedExclusions(group, targetId, resourcePhaseIds, objects); + for (const path of discovered) { + const pathFromGroup = relative(groupPath, path).split(sep).join("/"); + if (!synchronizedPathIsExcluded(pathFromGroup, excluded)) paths.add(path); + } + } + + return { paths: [...paths].sort() }; +} + +async function snapshotExistingFile( + path: string, + maximumBytes: number, +): Promise { + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) return undefined; + const bytes = new Uint8Array(await readFile(path)); + return { + path, + exists: true, + hash: sha256(bytes), + mode: info.mode & 0o7777, + bytes, + }; + } catch { + return undefined; + } +} + +async function snapshotOptionalFile( + root: string, + path: string, + maximumBytes: number, + missingMode: number, +): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) return undefined; + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) return undefined; + const bytes = new Uint8Array(await readFile(path)); + return { + path, + exists: true, + hash: sha256(bytes), + mode: info.mode & 0o7777, + bytes, + }; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return { path, exists: false, mode: missingMode }; + } + return undefined; + } +} + +function decodeUTF8(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return undefined; + } +} + +function parseXMLPlist(bytes: Uint8Array): Record | undefined { + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) return undefined; + const source = decodeUTF8(bytes); + if (!source) return undefined; + try { + const parsed = parseIOSPlist(source); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +async function hasGitMarkerInAncestors(start: string): Promise { + let directory = resolve(start); + while (true) { + try { + await lstat(resolve(directory, ".git")); + return true; + } catch { + // Walk to the filesystem root. + } + const parent = dirname(directory); + if (parent === directory) return false; + directory = parent; + } +} + +async function gitContext(root: string): Promise { + try { + const child = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + cwd: root, + stdout: "pipe", + stderr: "ignore", + }); + const output = (await new Response(child.stdout).text()).trim(); + if ((await child.exited) !== 0 || output === "") { + return (await hasGitMarkerInAncestors(root)) + ? { state: "unknown" } + : { state: "not-repository" }; + } + const [canonicalRepositoryRoot, canonicalRoot] = await Promise.all([ + realpath(output), + realpath(root), + ]); + const rootFromRepository = relative(canonicalRepositoryRoot, canonicalRoot); + if ( + rootFromRepository === ".." || + rootFromRepository.startsWith(`..${sep}`) || + isAbsolute(rootFromRepository) + ) { + return { state: "unknown" }; + } + return { state: "repository", root: canonicalRepositoryRoot }; + } catch { + return (await hasGitMarkerInAncestors(root)) + ? { state: "unknown" } + : { state: "not-repository" }; + } +} + +async function coherentGitContext(root: string, locations: string[]): Promise { + const contexts = await Promise.all([gitContext(root), ...locations.map(gitContext)]); + if (contexts.some((context) => context.state === "unknown")) return { state: "unknown" }; + const repositories = contexts.filter( + (context): context is Extract => + context.state === "repository", + ); + if (repositories.length === 0) return { state: "not-repository" }; + if ( + repositories.length !== contexts.length || + new Set(repositories.map((context) => context.root)).size !== 1 + ) { + return { state: "mismatch" }; + } + return repositories[0]!; +} + +async function hasDescendantGitignore( + rootInput: string, + localSecretsPath: string, +): Promise { + const root = resolve(rootInput); + let directory = dirname(resolve(localSecretsPath)); + const pathFromRoot = relative(root, directory); + if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) { + return true; + } + + while (directory !== root) { + try { + // A lower-level ignore file takes precedence over root rules. Treat every + // filesystem object here conservatively, including symlinks and directories. + await lstat(resolve(directory, ".gitignore")); + return true; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return true; + } + + const parent = dirname(directory); + if (parent === directory) return true; + directory = parent; + } + + return false; +} + +async function gitPathExitCode( + repositoryRoot: string, + args: string[], + absolutePath: string, +): Promise { + let canonicalPath: string; + try { + canonicalPath = await realpath(absolutePath); + } catch { + return undefined; + } + const path = relative(repositoryRoot, canonicalPath); + if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return undefined; + try { + const child = Bun.spawn(["git", ...args, "--", path], { + cwd: repositoryRoot, + stdout: "ignore", + stderr: "ignore", + }); + return await child.exited; + } catch { + return undefined; + } +} + +function escapeGitignorePath(path: string): string { + return path + .split("/") + .map((component) => { + let escaped = component.replaceAll("\\", "\\\\").replaceAll(" ", "\\ "); + for (const character of ["[", "]", "*", "?", "!", "#"]) { + escaped = escaped.replaceAll(character, `\\${character}`); + } + return escaped; + }) + .join("/"); +} + +function gitignoreRule(root: string, localSecretsPath: string): string { + return `/${escapeGitignorePath(relativeIOSPath(root, localSecretsPath))}`; +} + +function gitignoreTemporaryRule(root: string, localSecretsPath: string): string { + const components = relativeIOSPath(root, localSecretsPath).split("/"); + const fileName = components.pop()!; + const directory = components.length > 0 ? `${escapeGitignorePath(components.join("/"))}/` : ""; + return `/${directory}.${escapeGitignorePath(fileName)}.clerk-*.tmp`; +} + +function gitignoreContainsRule(content: string, rule: string): boolean { + return content.split(/\r?\n/).some((line) => line === rule); +} + +function gitignoreEndsWithRule(content: string, rule: string): boolean { + for (const line of content.split(/\r?\n/).reverse()) { + if (line.trim() === "" || line.startsWith("#")) continue; + return line === rule; + } + return false; +} + +function gitignoreRuleIsEffectiveWithoutRepository(content: string, rule: string): boolean { + const lines = content.split(/\r?\n/); + const ruleIndex = lines.lastIndexOf(rule); + if (ruleIndex < 0) return false; + + // Without Git there is no authoritative matcher available. A later negation + // could re-include this path (or its parent), so fail closed rather than + // inferring safety from the presence of a positive rule alone. + return !lines.slice(ruleIndex + 1).some((line) => line.startsWith("!")); +} + +function appendGitignoreRule(content: string, rule: string): string { + const lineEnding = content.includes("\r\n") ? "\r\n" : "\n"; + const separator = content.length > 0 && !content.endsWith("\n") ? lineEnding : ""; + return `${content}${separator}${rule}${lineEnding}`; +} + +function hasProvenRuntimeKeyWiring(target: IOSAppTarget | undefined): target is IOSAppTarget { + if (!target || !target.swift.evidenceComplete) return false; + const entryPoint = target.swift.entryPoints[0]; + const configureCall = target.swift.configureCalls[0]; + return ( + target.swift.entryPoints.length === 1 && + target.swift.configureCalls.length === 1 && + configureCall?.publishableKeyWiring === "local-secrets-loader" && + configureCall.localSecretsRuntimeBinding === "proven" && + configureCall.startupBinding === "app-init" && + configureCall.path === entryPoint?.path && + target.swift.localSecretsRuntimeBindings.length === 1 && + target.runtimeKeySinks.length === 1 + ); +} + +function exactStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return undefined; + return value; +} + +function optionalExactStringArray(value: unknown): string[] | undefined { + return value == null ? [] : exactStringArray(value); +} + +function isSameOrDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(parent, candidate); + return ( + pathFromParent === "" || + (!pathFromParent.startsWith(`..${sep}`) && + pathFromParent !== ".." && + !isAbsolute(pathFromParent)) + ); +} + +function sameFileIdentity( + left: { dev: number | bigint; ino: number | bigint }, + right: { dev: number | bigint; ino: number | bigint }, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function normalizedSynchronizedExceptionPath(path: string): string | undefined { + const normalized = normalizeSynchronizedPath(path); + if ( + normalized === "" || + normalized === ".." || + normalized.startsWith("../") || + normalized.startsWith("/") || + containsControlCharacter(normalized) + ) { + return undefined; + } + return normalized; +} + +function provenSynchronizedExclusions( + group: PbxObject, + targetId: string, + resourcePhaseIds: Set, + objects: PbxObjects, +): Set | undefined { + const exceptionIds = optionalExactStringArray(group.exceptions); + if (!exceptionIds) return undefined; + + const excluded = new Set(); + for (const exceptionId of exceptionIds) { + const exception = objects[exceptionId]; + if (!exception) return undefined; + + let applies = false; + if (exception.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet") { + const exceptionTargetId = asString(exception.target); + if (!exceptionTargetId || objects[exceptionTargetId]?.isa !== "PBXNativeTarget") { + return undefined; + } + applies = exceptionTargetId === targetId; + } else if (exception.isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet") { + const exceptionPhaseId = asString(exception.buildPhase); + const exceptionPhase = exceptionPhaseId ? objects[exceptionPhaseId] : undefined; + if ( + !exceptionPhaseId || + typeof exceptionPhase?.isa !== "string" || + !exceptionPhase.isa.endsWith("BuildPhase") + ) { + return undefined; + } + applies = resourcePhaseIds.has(exceptionPhaseId); + } else { + return undefined; + } + + if (!applies) continue; + const membershipExceptions = optionalExactStringArray(exception.membershipExceptions); + if (!membershipExceptions) return undefined; + for (const path of membershipExceptions) { + const normalized = normalizedSynchronizedExceptionPath(path); + if (!normalized) return undefined; + excluded.add(normalized); + } + + if (exception.platformFiltersByRelativePath == null) continue; + if (!isRecord(exception.platformFiltersByRelativePath)) return undefined; + for (const [path, rawFilters] of Object.entries(exception.platformFiltersByRelativePath)) { + const filters = exactStringArray(rawFilters); + const normalized = normalizedSynchronizedExceptionPath(path); + if (!filters || !normalized) return undefined; + const applicability = buildFileIOSApplicability({ platformFilters: filters }); + if (!applicability.recognized) return undefined; + if (!applicability.applies) excluded.add(normalized); + } + } + return excluded; +} + +interface RuntimeSinkIdentity { + dev: number | bigint; + ino: number | bigint; +} + +interface OwnershipScanState { + entries: number; + visitedDirectories: Set; +} + +async function synchronizedDirectoryOwnsCanonicalSink(options: { + canonicalDirectory: string; + canonicalSink: string; + excluded: Set; + logicalPrefix: string; + sinkIdentity: RuntimeSinkIdentity; + state: OwnershipScanState; + depth?: number; +}): Promise { + const { + canonicalDirectory, + canonicalSink, + excluded, + logicalPrefix, + sinkIdentity, + state, + depth = 0, + } = options; + if (depth > MAX_DISCOVERY_DEPTH) return undefined; + + if (isSameOrDescendant(canonicalDirectory, canonicalSink)) { + const pathFromDirectory = relative(canonicalDirectory, canonicalSink).split(sep).join("/"); + const logicalSinkPath = normalizeSynchronizedPath( + logicalPrefix ? `${logicalPrefix}/${pathFromDirectory}` : pathFromDirectory, + ); + if (!synchronizedPathIsExcluded(logicalSinkPath, excluded)) return true; + } + + const visitKey = `${canonicalDirectory}\0${logicalPrefix}`; + if (state.visitedDirectories.has(visitKey)) return false; + state.visitedDirectories.add(visitKey); + + let entries; + try { + entries = await readdir(canonicalDirectory, { withFileTypes: true }); + } catch { + return undefined; + } + state.entries += entries.length; + if (state.entries > MAX_OWNERSHIP_SCAN_ENTRIES) return undefined; + + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const logicalPath = normalizeSynchronizedPath( + logicalPrefix ? `${logicalPrefix}/${entry.name}` : entry.name, + ); + if (synchronizedPathIsExcluded(logicalPath, excluded)) continue; + + const entryPath = resolve(canonicalDirectory, entry.name); + let entryInfo; + try { + entryInfo = await lstat(entryPath); + } catch { + return undefined; + } + + if (entryInfo.isFile()) { + if (sameFileIdentity(entryInfo, sinkIdentity)) return true; + continue; + } + + if (!entryInfo.isDirectory() && !entryInfo.isSymbolicLink()) continue; + let canonicalEntry: string; + let canonicalEntryInfo; + try { + canonicalEntry = await realpath(entryPath); + canonicalEntryInfo = await lstat(canonicalEntry); + } catch { + // A dangling or unreadable alias could conceal a second path to the sink. + return undefined; + } + + if (canonicalEntryInfo.isFile()) { + if (sameFileIdentity(canonicalEntryInfo, sinkIdentity)) return true; + continue; + } + if (!canonicalEntryInfo.isDirectory()) continue; + + const nestedOwnership = await synchronizedDirectoryOwnsCanonicalSink({ + canonicalDirectory: canonicalEntry, + canonicalSink, + excluded, + logicalPrefix: logicalPath, + sinkIdentity, + state, + depth: depth + 1, + }); + if (nestedOwnership == null || nestedOwnership) return nestedOwnership; + } + return false; +} + +async function synchronizedGroupOwnsCanonicalSink(options: { + groupPath: string; + canonicalSink: string; + excluded: Set; + sinkIdentity: RuntimeSinkIdentity; +}): Promise { + let canonicalGroup: string; + let groupInfo; + try { + canonicalGroup = await realpath(options.groupPath); + groupInfo = await lstat(canonicalGroup); + } catch { + return undefined; + } + if (!groupInfo.isDirectory()) return undefined; + + return synchronizedDirectoryOwnsCanonicalSink({ + canonicalDirectory: canonicalGroup, + canonicalSink: options.canonicalSink, + excluded: options.excluded, + logicalPrefix: "", + sinkIdentity: options.sinkIdentity, + state: { entries: 0, visitedDirectories: new Set() }, + }); +} + +async function classicReferenceOwnsCanonicalSink(options: { + referenceId: string; + canonicalSink: string; + sinkIdentity: RuntimeSinkIdentity; + objects: PbxObjects; + parents: Map; + projectDirectory: string; + groupRootDirectory: string; + seen?: Set; +}): Promise { + const { + referenceId, + canonicalSink, + sinkIdentity, + objects, + parents, + projectDirectory, + groupRootDirectory, + seen = new Set(), + } = options; + if (seen.has(referenceId)) return undefined; + seen.add(referenceId); + + const reference = objects[referenceId]; + if (!reference) return undefined; + if (["PBXVariantGroup", "XCVersionGroup", "PBXGroup"].includes(reference.isa ?? "")) { + const children = exactStringArray(reference.children); + if (!children) return undefined; + let ownsSink = false; + for (const child of children) { + const childOwnership = await classicReferenceOwnsCanonicalSink({ + referenceId: child, + canonicalSink, + sinkIdentity, + objects, + parents, + projectDirectory, + groupRootDirectory, + seen: new Set(seen), + }); + if (childOwnership == null) return undefined; + ownsSink ||= childOwnership; + } + return ownsSink; + } + if (reference.isa !== "PBXFileReference") return undefined; + + const path = resolvePbxFilePath( + referenceId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!path) return undefined; + + let info; + try { + info = await lstat(path); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" && + basename(path) !== "LocalSecrets.plist" + ) { + return false; + } + return undefined; + } + + if (info.isFile() && !info.isSymbolicLink()) { + return sameFileIdentity(info, sinkIdentity); + } + + let canonicalReference: string; + let canonicalInfo; + try { + canonicalReference = await realpath(path); + canonicalInfo = await lstat(canonicalReference); + } catch { + return undefined; + } + if (canonicalInfo.isFile()) return sameFileIdentity(canonicalInfo, sinkIdentity); + if (!canonicalInfo.isDirectory()) return false; + + return synchronizedDirectoryOwnsCanonicalSink({ + canonicalDirectory: canonicalReference, + canonicalSink, + excluded: new Set(), + logicalPrefix: "", + sinkIdentity, + state: { entries: 0, visitedDirectories: new Set() }, + }); +} + +async function targetOwnsCanonicalRuntimeSink(options: { + canonicalSink: string; + groupRootDirectory: string; + objects: PbxObjects; + parents: Map; + projectDirectory: string; + sinkIdentity: RuntimeSinkIdentity; + target: PbxObject; + targetId: string; +}): Promise { + const { + canonicalSink, + groupRootDirectory, + objects, + parents, + projectDirectory, + sinkIdentity, + target, + targetId, + } = options; + const buildPhaseIds = exactStringArray(target.buildPhases); + if (!buildPhaseIds) return undefined; + + const resourcePhaseIds = new Set(); + for (const phaseId of buildPhaseIds) { + const phase = objects[phaseId]; + if (typeof phase?.isa !== "string" || !phase.isa.endsWith("BuildPhase")) return undefined; + if (phase.isa === "PBXResourcesBuildPhase") resourcePhaseIds.add(phaseId); + } + + let ownsSink = false; + for (const phaseId of resourcePhaseIds) { + const phase = objects[phaseId]!; + const buildFileIds = exactStringArray(phase.files); + if (!buildFileIds) return undefined; + for (const buildFileId of buildFileIds) { + const buildFile = objects[buildFileId]; + if (buildFile?.isa !== "PBXBuildFile") return undefined; + const applicability = buildFileIOSApplicability(buildFile); + if (!applicability.recognized) return undefined; + if (!applicability.applies) continue; + const referenceId = asString(buildFile.fileRef); + if (!referenceId) return undefined; + const referenceOwnership = await classicReferenceOwnsCanonicalSink({ + referenceId, + canonicalSink, + sinkIdentity, + objects, + parents, + projectDirectory, + groupRootDirectory, + }); + if (referenceOwnership == null) return undefined; + ownsSink ||= referenceOwnership; + } + } + + const synchronizedGroupIds = optionalExactStringArray(target.fileSystemSynchronizedGroups); + if (!synchronizedGroupIds) return undefined; + for (const groupId of synchronizedGroupIds) { + const group = objects[groupId]; + if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") return undefined; + const groupPath = resolvePbxFilePath( + groupId, + objects, + parents, + projectDirectory, + groupRootDirectory, + ); + if (!groupPath) return undefined; + const excluded = provenSynchronizedExclusions(group, targetId, resourcePhaseIds, objects); + if (!excluded) return undefined; + const synchronizedOwnership = await synchronizedGroupOwnsCanonicalSink({ + groupPath, + canonicalSink, + excluded, + sinkIdentity, + }); + if (synchronizedOwnership == null) return undefined; + ownsSink ||= synchronizedOwnership; + } + + return ownsSink; +} + +async function hasExclusiveRuntimeSinkOwnership( + root: string, + projectPath: string, + targetId: string, + localSecretsPath: string, +): Promise { + let canonicalSink: string; + let sinkIdentity: RuntimeSinkIdentity; + try { + canonicalSink = await realpath(localSecretsPath); + const sinkInfo = await lstat(canonicalSink); + if (!sinkInfo.isFile()) return false; + sinkIdentity = { dev: sinkInfo.dev, ino: sinkInfo.ino }; + } catch { + return false; + } + + const absoluteProjectPath = resolve(root, projectPath); + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + + let archive: unknown; + try { + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } + + if (!isRecord(archive)) return false; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") return false; + + const projectTargetIds = exactStringArray(projectObject.targets); + if (!projectTargetIds || !projectTargetIds.includes(targetId)) return false; + const parents = buildPbxParentIndex(objects); + const projectDirectory = dirname(absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); + + const owners = new Set(); + for (const candidateTargetId of projectTargetIds) { + const target = objects[candidateTargetId]; + if (!target) return false; + if (target.isa !== "PBXNativeTarget" || asString(target.productType) !== APP_PRODUCT_TYPE) { + continue; + } + + const ownership = await targetOwnsCanonicalRuntimeSink({ + canonicalSink, + groupRootDirectory, + objects, + parents, + projectDirectory, + sinkIdentity, + target, + targetId: candidateTargetId, + }); + if (ownership == null) return false; + if (ownership) owners.add(candidateTargetId); + } + return owners.size === 1 && owners.has(targetId); +} + +async function prepareRuntimeKeyVerification( + options: IOSRuntimeKeyPlanOptions, +): Promise { + const root = resolve(options.root); + const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); + if ( + !options.targetId || + !suppliedProjectPath || + isAbsolute(options.projectPath) || + !suppliedProjectPath.endsWith(".xcodeproj") || + (options.localSecretsPath != null && isAbsolute(options.localSecretsPath)) + ) { + return verificationBlocked( + options, + root, + suppliedProjectPath, + "invalid-selection", + "A root-relative Xcode project, application target, and optional root-relative LocalSecrets path are required.", + ); + } + + const absoluteProjectPath = resolve(root, suppliedProjectPath); + const projectPath = relativeIOSPath(root, absoluteProjectPath); + if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { + return verificationBlocked( + options, + root, + projectPath, + "external-path", + "The selected Xcode project resolves outside the project root.", + ); + } + + const inspection = await inspectIOSProject(root, { target: options.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return verificationBlocked( + options, + root, + projectPath, + "target-not-found", + "The selected application target could not be verified in the selected Xcode project.", + ); + } + const selectedTarget = inspection.appTargets.find( + (target) => target.id === options.targetId && target.projectPath === projectPath, + ); + if (!hasProvenRuntimeKeyWiring(selectedTarget)) { + return verificationBlocked( + options, + root, + projectPath, + "unproven-runtime-wiring", + "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", + ); + } + if ( + inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) + ) { + return verificationBlocked( + options, + root, + projectPath, + "scheme-override", + "The selected target has an enabled CLERK_PUBLISHABLE_KEY Run-scheme override, so LocalSecrets.plist is not the exclusive runtime key source.", + ); + } + + const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); + if (membership.blocker) { + return verificationBlocked( + options, + root, + projectPath, + membership.blocker.code, + membership.blocker.message, + ); + } + const memberPaths = membership.paths ?? []; + let localSecretsPath: string | undefined; + if (options.localSecretsPath != null) { + const requestedPath = resolve(root, options.localSecretsPath); + if (!(await pathIsSafelyWithinIOSRoot(root, requestedPath))) { + return verificationBlocked( + options, + root, + projectPath, + "external-path", + "The requested LocalSecrets.plist resolves outside the project root.", + ); + } + localSecretsPath = memberPaths.find((path) => resolve(path) === requestedPath); + if (!localSecretsPath) { + return verificationBlocked( + options, + root, + projectPath, + "not-target-resource", + "The requested LocalSecrets.plist is not a proven resource of the selected target.", + ); + } + } else if (memberPaths.length === 0) { + return verificationBlocked( + options, + root, + projectPath, + "missing-local-secrets", + "The selected target does not already own a LocalSecrets.plist resource.", + ); + } else if (memberPaths.length > 1) { + return verificationBlocked( + options, + root, + projectPath, + "ambiguous-local-secrets", + "The selected target owns more than one LocalSecrets.plist resource; select one explicitly.", + ); + } else { + localSecretsPath = memberPaths[0]; + } + + if ( + !localSecretsPath || + basename(localSecretsPath) !== "LocalSecrets.plist" || + resolve(root, selectedTarget.runtimeKeySinks[0]!.path) !== resolve(localSecretsPath) + ) { + return verificationBlocked( + options, + root, + projectPath, + "not-target-resource", + "A unique target-owned LocalSecrets.plist resource could not be resolved.", + ); + } + if ( + !(await hasExclusiveRuntimeSinkOwnership(root, projectPath, options.targetId, localSecretsPath)) + ) { + return verificationBlocked( + options, + root, + projectPath, + "shared-local-secrets", + "LocalSecrets.plist must be owned exclusively by the selected iOS application target before its runtime key can be verified.", + ); + } + + const localSecretsRelativePath = relativeIOSPath(root, localSecretsPath); + const redactedSource = { + plan: makeVerificationPlan(options, root, projectPath, "ready", { + localSecretsPath: localSecretsRelativePath, + }), + }; + const localSecretsSnapshot = await snapshotExistingFile( + localSecretsPath, + MAX_LOCAL_SECRETS_BYTES, + ); + if (!localSecretsSnapshot) { + return verificationBlocked( + options, + root, + projectPath, + "unreadable-local-secrets", + "LocalSecrets.plist is missing, too large, symlinked, or unreadable.", + redactedSource, + ); + } + const plist = parseXMLPlist(localSecretsSnapshot.bytes!); + if (!plist) { + return verificationBlocked( + options, + root, + projectPath, + "malformed-local-secrets", + "LocalSecrets.plist must be a readable XML property-list dictionary.", + redactedSource, + ); + } + const existingPublishableKey = existingValidPublishableKey(plist); + if ( + !existingPublishableKey || + plist[SECRET_KEY] !== existingPublishableKey || + !inspection.localPublishableKey.found || + inspection.localPublishableKey.conflict || + inspection.localPublishableKey.source !== localSecretsRelativePath + ) { + return verificationBlocked( + options, + root, + projectPath, + "invalid-publishable-key", + "The proven LocalSecrets.plist runtime sink does not contain one canonical publishable key that can be verified.", + redactedSource, + ); + } + + return { + plan: makeVerificationPlan(options, root, projectPath, "ready", { + localSecretsPath: localSecretsRelativePath, + expectedLocalSecretsHash: localSecretsSnapshot.hash, + }), + localSecretsSnapshot, + existingPublishableKey, + }; +} + +export async function planIOSRuntimeKeyVerification( + options: IOSRuntimeKeyPlanOptions, +): Promise { + return (await prepareRuntimeKeyVerification(options)).plan; +} + +export async function verifyIOSRuntimeKey( + plan: IOSRuntimeKeyVerificationPlan, + linkedPublishableKey: string, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-runtime-key-verification" || + !plan.localSecretsPath || + !plan.expectedLocalSecretsHash + ) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "invalid-selection", + message: "The runtime-key verification plan is incomplete or unsupported.", + }, + ], + }, + }; + } + + const linkedKey = validatePublishableKey(linkedPublishableKey); + if (!linkedKey || linkedKey.value !== linkedPublishableKey) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { code: "invalid-publishable-key", message: "A valid publishable key is required." }, + ], + }, + }; + } + if (linkedKey.instanceType !== "development") { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "production-publishable-key", + message: "Runtime-key verification accepts a development-instance key only.", + }, + ], + }, + }; + } + + const prepared = await prepareRuntimeKeyVerification({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + localSecretsPath: plan.localSecretsPath, + }); + if (prepared.plan.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if ( + prepared.plan.expectedLocalSecretsHash !== plan.expectedLocalSecretsHash || + !prepared.localSecretsSnapshot || + !(await snapshotMatches(prepared.localSecretsSnapshot)) + ) { + return { status: "stale", plan }; + } + + return { + status: prepared.existingPublishableKey === linkedKey.value ? "matched" : "mismatched", + plan, + }; +} + +async function prepareRuntimeKeyPlan( + options: IOSRuntimeKeyPlanOptions, +): Promise { + const root = resolve(options.root); + const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); + if ( + !options.targetId || + !suppliedProjectPath || + isAbsolute(options.projectPath) || + !suppliedProjectPath.endsWith(".xcodeproj") || + (options.localSecretsPath != null && isAbsolute(options.localSecretsPath)) + ) { + return blocked( + options, + root, + suppliedProjectPath, + "invalid-selection", + "A root-relative Xcode project, application target, and optional root-relative LocalSecrets path are required.", + ); + } + + const absoluteProjectPath = resolve(root, suppliedProjectPath); + const projectPath = relativeIOSPath(root, absoluteProjectPath); + if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { + return blocked( + options, + root, + projectPath, + "external-path", + "The selected Xcode project resolves outside the project root.", + ); + } + + const inspection = await inspectIOSProject(root, { target: options.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected application target could not be verified in the selected Xcode project.", + ); + } + const selectedTarget = inspection.appTargets.find( + (target) => target.id === options.targetId && target.projectPath === projectPath, + ); + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (generator) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated target resources.`, + ); + } + if (!hasProvenRuntimeKeyWiring(selectedTarget)) { + return blocked( + options, + root, + projectPath, + "unproven-runtime-wiring", + "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", + ); + } + if ( + inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) + ) { + return blocked( + options, + root, + projectPath, + "scheme-override", + "The selected target has an enabled CLERK_PUBLISHABLE_KEY Run-scheme override. Disable or remove it before managing LocalSecrets.plist.", + ); + } + + const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); + if (membership.blocker) { + return blocked(options, root, projectPath, membership.blocker.code, membership.blocker.message); + } + const memberPaths = membership.paths ?? []; + let localSecretsPath: string | undefined; + if (options.localSecretsPath != null) { + const requestedPath = resolve(root, options.localSecretsPath); + if (!(await pathIsSafelyWithinIOSRoot(root, requestedPath))) { + return blocked( + options, + root, + projectPath, + "external-path", + "The requested LocalSecrets.plist resolves outside the project root.", + ); + } + localSecretsPath = memberPaths.find((path) => resolve(path) === requestedPath); + if (!localSecretsPath) { + return blocked( + options, + root, + projectPath, + "not-target-resource", + "The requested LocalSecrets.plist is not a proven resource of the selected target.", + ); + } + } else if (memberPaths.length === 0) { + return blocked( + options, + root, + projectPath, + "missing-local-secrets", + "The selected target does not already own a LocalSecrets.plist resource.", + ); + } else if (memberPaths.length > 1) { + return blocked( + options, + root, + projectPath, + "ambiguous-local-secrets", + "The selected target owns more than one LocalSecrets.plist resource; select one explicitly.", + ); + } else { + localSecretsPath = memberPaths[0]; + } + + if ( + !localSecretsPath || + basename(localSecretsPath) !== "LocalSecrets.plist" || + resolve(root, selectedTarget.runtimeKeySinks[0]!.path) !== resolve(localSecretsPath) + ) { + return blocked( + options, + root, + projectPath, + "not-target-resource", + "A unique target-owned LocalSecrets.plist resource could not be resolved.", + ); + } + if ( + !(await hasExclusiveRuntimeSinkOwnership(root, projectPath, options.targetId, localSecretsPath)) + ) { + return blocked( + options, + root, + projectPath, + "shared-local-secrets", + "LocalSecrets.plist must be owned exclusively by the selected iOS application target before it can be updated automatically.", + ); + } + const localSecretsRelativePath = relativeIOSPath(root, localSecretsPath); + if (containsControlCharacter(localSecretsRelativePath)) { + return blocked( + options, + root, + projectPath, + "unsafe-gitignore", + "The LocalSecrets.plist path contains control characters that cannot be represented safely in .gitignore.", + ); + } + const localSecretsSnapshot = await snapshotExistingFile( + localSecretsPath, + MAX_LOCAL_SECRETS_BYTES, + ); + const redactedSource = { + plan: makePlan(options, root, projectPath, "ready", { + localSecretsPath: relativeIOSPath(root, localSecretsPath), + }), + }; + if (!localSecretsSnapshot) { + return blocked( + options, + root, + projectPath, + "unreadable-local-secrets", + "LocalSecrets.plist is missing, too large, symlinked, or unreadable.", + redactedSource, + ); + } + const plist = parseXMLPlist(localSecretsSnapshot.bytes!); + if (!plist) { + return blocked( + options, + root, + projectPath, + "malformed-local-secrets", + "LocalSecrets.plist must be a readable XML property-list dictionary.", + redactedSource, + ); + } + if (plist[SECRET_KEY] != null && typeof plist[SECRET_KEY] !== "string") { + return blocked( + options, + root, + projectPath, + "unsupported-local-secrets", + "The CLERK_PUBLISHABLE_KEY entry in LocalSecrets.plist must be a string.", + redactedSource, + ); + } + const existingNormalizedKey = existingValidPublishableKey(plist); + const plistMayNeedWrite = + existingNormalizedKey == null || plist[SECRET_KEY] !== existingNormalizedKey; + + const resolvedGitContext = await coherentGitContext(root, [ + absoluteProjectPath, + dirname(localSecretsPath), + ]); + if (resolvedGitContext.state === "unknown") { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + "Git could not verify whether LocalSecrets.plist is tracked or ignored.", + redactedSource, + ); + } + if (resolvedGitContext.state === "mismatch") { + return blocked( + options, + root, + projectPath, + "git-repository-mismatch", + "The selected Xcode project and LocalSecrets.plist must share the invocation root's Git repository boundary.", + redactedSource, + ); + } + if (await hasDescendantGitignore(root, localSecretsPath)) { + return blocked( + options, + root, + projectPath, + "unsafe-gitignore", + "A nested .gitignore can override the invocation root's LocalSecrets.plist protection. Consolidate the sink's ignore rules at the invocation root before retrying.", + redactedSource, + ); + } + if (resolvedGitContext.state === "repository") { + const tracked = await gitPathExitCode( + resolvedGitContext.root, + ["ls-files", "--error-unmatch"], + localSecretsPath, + ); + if (tracked == null) { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + "Git could not verify whether LocalSecrets.plist is tracked.", + redactedSource, + ); + } + if (tracked > 1) { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + "Git could not verify whether LocalSecrets.plist is tracked.", + redactedSource, + ); + } + if (tracked === 0) { + return blocked( + options, + root, + projectPath, + "tracked-local-secrets", + "LocalSecrets.plist is tracked by Git. Remove it from the index before writing a publishable key.", + redactedSource, + ); + } + } + + const gitignorePath = resolve(root, ".gitignore"); + const gitignoreSnapshot = await snapshotOptionalFile( + root, + gitignorePath, + MAX_GITIGNORE_BYTES, + 0o644, + ); + if (!gitignoreSnapshot) { + return blocked( + options, + root, + projectPath, + "unsafe-gitignore", + ".gitignore is too large, symlinked, unreadable, or resolves outside the project root.", + redactedSource, + ); + } + const rule = gitignoreRule(root, localSecretsPath); + const gitignoreText = gitignoreSnapshot.exists ? decodeUTF8(gitignoreSnapshot.bytes!) : ""; + if (gitignoreText == null) { + return blocked( + options, + root, + projectPath, + "unsafe-gitignore", + ".gitignore must be valid UTF-8.", + redactedSource, + ); + } + + const hasExactRule = gitignoreContainsRule(gitignoreText, rule); + let effectivelyIgnored = hasExactRule && gitignoreEndsWithRule(gitignoreText, rule); + if (resolvedGitContext.state === "repository") { + const ignored = await gitPathExitCode( + resolvedGitContext.root, + ["check-ignore", "--quiet", "--no-index"], + localSecretsPath, + ); + if (ignored == null || ignored > 1) { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + "Git could not verify whether LocalSecrets.plist is effectively ignored.", + redactedSource, + ); + } + effectivelyIgnored = ignored === 0; + } + const gitignoreNeeded = !hasExactRule || !effectivelyIgnored; + const changesGitignore = gitignoreNeeded || plistMayNeedWrite; + + const gitignoreRelativePath = relativeIOSPath(root, gitignorePath); + return { + plan: makePlan(options, root, projectPath, "ready", { + localSecretsPath: localSecretsRelativePath, + gitignorePath: gitignoreRelativePath, + gitignoreRule: rule, + expectedLocalSecretsHash: localSecretsSnapshot.hash, + expectedGitignoreHash: gitignoreSnapshot.exists ? gitignoreSnapshot.hash! : null, + changesGitignore, + actions: [ + ...(changesGitignore + ? [ + `Ensure ${localSecretsRelativePath} and its atomic-write staging file are effectively ignored by Git.`, + ] + : []), + `Set CLERK_PUBLISHABLE_KEY in ${localSecretsRelativePath} without exposing its value.`, + ], + }), + plist, + localSecretsSnapshot, + gitignoreSnapshot, + gitContext: resolvedGitContext, + gitignoreNeeded, + }; +} + +export async function planIOSRuntimeKey( + options: IOSRuntimeKeyPlanOptions, +): Promise { + return (await prepareRuntimeKeyPlan(options)).plan; +} + +function validatePublishableKey( + value: string, +): { value: string; instanceType: "development" | "production" } | undefined { + const normalized = value.trim(); + if (!normalized) return undefined; + try { + return { value: normalized, instanceType: decodePublishableKey(normalized).instanceType }; + } catch { + return undefined; + } +} + +function existingValidPublishableKey(plist: Record): string | undefined { + const value = plist[SECRET_KEY]; + if (typeof value !== "string") return undefined; + return validatePublishableKey(value)?.value; +} + +function xmlEscape(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function plistWithoutPublishableKey(plist: Record): Record { + return Object.fromEntries(Object.entries(plist).filter(([key]) => key !== SECRET_KEY)); +} + +function replaceOrInsertPublishableKey( + originalBytes: Uint8Array, + originalPlist: Record, + publishableKey: string, +): Uint8Array | undefined { + const source = decodeUTF8(originalBytes); + if (!source) return undefined; + const keyTag = /\s*CLERK_PUBLISHABLE_KEY\s*<\/key>/g; + const matches = [...source.matchAll(keyTag)]; + if (matches.length > 1) return undefined; + if (matches.length === 0 && Object.hasOwn(originalPlist, SECRET_KEY)) return undefined; + + let candidate: string; + if (matches.length === 1) { + const match = matches[0]!; + const keyEnd = match.index! + match[0].length; + const suffix = source.slice(keyEnd); + const stringValue = /^(\s*)([\s\S]*?)<\/string>/.exec(suffix); + const emptyStringValue = /^(\s*)/.exec(suffix); + if (stringValue) { + const replacement = `${stringValue[1]}${xmlEscape(publishableKey)}`; + candidate = `${source.slice(0, keyEnd)}${replacement}${suffix.slice(stringValue[0].length)}`; + } else if (emptyStringValue) { + const replacement = `${emptyStringValue[1]}${xmlEscape(publishableKey)}`; + candidate = `${source.slice(0, keyEnd)}${replacement}${suffix.slice(emptyStringValue[0].length)}`; + } else { + return undefined; + } + } else { + const closing = source.lastIndexOf(""); + if (closing === -1) return undefined; + const lineEnding = source.includes("\r\n") ? "\r\n" : "\n"; + const lineStart = source.lastIndexOf("\n", closing - 1) + 1; + const possibleIndent = source.slice(lineStart, closing); + if (/^[\t ]*$/.test(possibleIndent)) { + const childIndent = `${possibleIndent}${source.includes("\t") ? "\t" : " "}`; + const insertion = `${childIndent}${SECRET_KEY}${lineEnding}${childIndent}${xmlEscape(publishableKey)}${lineEnding}`; + candidate = `${source.slice(0, lineStart)}${insertion}${source.slice(lineStart)}`; + } else { + candidate = `${source.slice(0, closing)}${SECRET_KEY}${xmlEscape(publishableKey)}${source.slice(closing)}`; + } + } + + const candidateBytes = new TextEncoder().encode(candidate); + const candidatePlist = parseXMLPlist(candidateBytes); + if ( + !candidatePlist || + candidatePlist[SECRET_KEY] !== publishableKey || + !isDeepStrictEqual( + plistWithoutPublishableKey(originalPlist), + plistWithoutPublishableKey(candidatePlist), + ) + ) { + return undefined; + } + return candidateBytes; +} + +async function snapshotMatches(snapshot: FileSnapshot): Promise { + try { + const info = await lstat(snapshot.path); + if (!snapshot.exists) return false; + if (!info.isFile() || info.isSymbolicLink()) return false; + return sha256(await readFile(snapshot.path)) === snapshot.hash; + } catch (error) { + return !snapshot.exists && error instanceof Error && "code" in error && error.code === "ENOENT"; + } +} + +async function fileMatchesHash( + path: string, + maximumBytes: number, + expectedHash: string, +): Promise { + const snapshot = await snapshotExistingFile(path, maximumBytes); + return snapshot?.hash === expectedHash; +} + +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/link remains atomic when directory fsync is unavailable. + } +} + +async function stageFile( + snapshot: FileSnapshot, + content: Uint8Array, + options: StageFileOptions = {}, +): Promise { + const temporaryPath = resolve( + dirname(snapshot.path), + `.${basename(snapshot.path)}.clerk-${process.pid}-${randomUUID()}.tmp`, + ); + let created = false; + try { + const file = await open(temporaryPath, "wx", snapshot.mode); + created = true; + try { + if (options.beforeWrite && !(await options.beforeWrite(temporaryPath))) { + throw new Error("temporary path is not safely ignored"); + } + await file.writeFile(content); + if (options.forceFailureAfterCreate) throw new Error("injected staging failure"); + await file.sync(); + } finally { + await file.close(); + } + await chmod(temporaryPath, snapshot.mode); + } catch { + if (created) { + try { + await rm(temporaryPath, { force: true }); + } catch { + throw new RuntimeKeyTemporaryFileCleanupError( + "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", + options.keyBearing === true, + ); + } + } + throw new Error("The runtime-key update could not be staged safely."); + } + return { + targetPath: snapshot.path, + temporaryPath, + candidateHash: sha256(content), + original: snapshot, + committed: false, + cleanupFailuresRemaining: options.cleanupFailures ?? 0, + keyBearing: options.keyBearing === true, + }; +} + +async function removeStagedTemporaryFile(staged: StagedFile): Promise { + if (staged.cleanupFailuresRemaining > 0) { + staged.cleanupFailuresRemaining -= 1; + throw new RuntimeKeyTemporaryFileCleanupError( + "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", + staged.keyBearing, + ); + } + try { + await rm(staged.temporaryPath, { force: true }); + } catch { + throw new RuntimeKeyTemporaryFileCleanupError( + "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", + staged.keyBearing, + ); + } +} + +async function commitStagedFile(staged: StagedFile): Promise<"written" | "stale"> { + if (!(await snapshotMatches(staged.original))) return "stale"; + if (staged.original.exists) { + await rename(staged.temporaryPath, staged.targetPath); + staged.committed = true; + } else { + try { + await link(staged.temporaryPath, staged.targetPath); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EEXIST") return "stale"; + throw error; + } + staged.committed = true; + await removeStagedTemporaryFile(staged); + } + await syncDirectory(dirname(staged.targetPath)); + return "written"; +} + +async function cleanupStagedFile(staged: StagedFile): Promise { + await removeStagedTemporaryFile(staged); +} + +async function restoreCommittedFile(staged: StagedFile): Promise<"restored" | "stale"> { + const current = await snapshotExistingFile(staged.targetPath, Number.MAX_SAFE_INTEGER); + if (!current || current.hash !== staged.candidateHash) return "stale"; + if (!staged.original.exists) { + await rm(staged.targetPath); + await syncDirectory(dirname(staged.targetPath)); + staged.committed = false; + return "restored"; + } + const rollback = await stageFile(current, staged.original.bytes!, { + keyBearing: staged.keyBearing, + }); + try { + if ((await commitStagedFile(rollback)) !== "written") return "stale"; + staged.committed = false; + return "restored"; + } finally { + await cleanupStagedFile(rollback); + } +} + +async function rollbackFiles( + stagedFiles: StagedFile[], + dependency: RollbackDependency, + preserveProtection = false, +): Promise { + let fullyRestored = true; + let payloadIsUnsafe = preserveProtection; + let cleanupFailure: RuntimeKeyTemporaryFileCleanupError | undefined; + for (const staged of stagedFiles) { + if (staged.targetPath !== dependency.payloadPath || staged.committed) continue; + try { + await cleanupStagedFile(staged); + } catch (error) { + fullyRestored = false; + payloadIsUnsafe = true; + if (error instanceof RuntimeKeyTemporaryFileCleanupError) { + cleanupFailure ??= error; + } + } + } + const payload = stagedFiles.find( + (staged) => staged.committed && staged.targetPath === dependency.payloadPath, + ); + const ordered = [ + ...(payload ? [payload] : []), + ...[...stagedFiles].reverse().filter((staged) => staged !== payload), + ]; + for (const staged of ordered) { + if (!staged.committed) continue; + if (staged.targetPath === dependency.protectionPath && payloadIsUnsafe) { + fullyRestored = false; + continue; + } + try { + const restoreResult = await restoreCommittedFile(staged); + if (restoreResult === "restored") { + continue; + } + if (staged.targetPath === dependency.protectionPath && !payloadIsUnsafe) { + // The payload is back to a non-key-bearing state, so retain a concurrent + // ignore-file edit instead of overwriting it merely to restore our guard. + continue; + } + } catch (error) { + if (error instanceof RuntimeKeyTemporaryFileCleanupError) { + cleanupFailure ??= error; + if (staged.targetPath === dependency.payloadPath) payloadIsUnsafe = true; + if (!staged.committed) continue; + } + // Continue so independent files are still restored when it is safe to do so. + } + fullyRestored = false; + if (staged.targetPath === dependency.payloadPath) payloadIsUnsafe = true; + } + if (payloadIsUnsafe) { + const unsafeKeyBearingPaths = stagedFiles + .filter((staged) => staged.keyBearing) + .map((staged) => (staged.committed ? staged.targetPath : staged.temporaryPath)); + if (!(await ensureRollbackProtection(dependency, unsafeKeyBearingPaths))) { + fullyRestored = false; + } + } + if (cleanupFailure) throw cleanupFailure; + return fullyRestored; +} + +async function ensureRollbackProtection( + dependency: RollbackDependency, + keyBearingPaths: string[], +): Promise { + if ( + keyBearingPaths.length > 0 && + ( + await Promise.all( + keyBearingPaths.map(async (path) => + localSecretsIsIgnored( + dependency.root, + path, + path === dependency.payloadPath + ? dependency.protectionRules.at(-1)! + : dependency.protectionRules[0]!, + ), + ), + ) + ).every(Boolean) + ) { + return true; + } + + const current = await snapshotOptionalFile( + dependency.root, + dependency.protectionPath, + MAX_GITIGNORE_BYTES, + 0o644, + ); + if (!current) return false; + const currentText = current.exists ? decodeUTF8(current.bytes!) : ""; + if (currentText == null) return false; + + let protectedText = currentText; + for (const rule of dependency.protectionRules) { + protectedText = appendGitignoreRule(protectedText, rule); + } + const protection = await stageFile(current, new TextEncoder().encode(protectedText)); + try { + if ((await commitStagedFile(protection)) !== "written") return false; + } finally { + await cleanupStagedFile(protection); + } + + return ( + await Promise.all( + keyBearingPaths.map(async (path) => + localSecretsIsIgnored( + dependency.root, + path, + path === dependency.payloadPath + ? dependency.protectionRules.at(-1)! + : dependency.protectionRules[0]!, + ), + ), + ) + ).every(Boolean); +} + +async function localSecretsIsIgnored( + root: string, + localSecretsPath: string, + rule: string, +): Promise { + if (await hasDescendantGitignore(root, localSecretsPath)) return false; + const gitignore = await snapshotExistingFile(resolve(root, ".gitignore"), MAX_GITIGNORE_BYTES); + const gitignoreText = gitignore?.bytes ? decodeUTF8(gitignore.bytes) : undefined; + if (gitignoreText == null || !gitignoreContainsRule(gitignoreText, rule)) return false; + const context = await coherentGitContext(root, [dirname(localSecretsPath)]); + if (context.state === "repository") { + const tracked = await gitPathExitCode( + context.root, + ["ls-files", "--error-unmatch"], + localSecretsPath, + ); + if (tracked !== 1) return false; + const ignored = await gitPathExitCode( + context.root, + ["check-ignore", "--quiet", "--no-index"], + localSecretsPath, + ); + return ignored === 0; + } + return ( + context.state === "not-repository" && + gitignoreRuleIsEffectiveWithoutRepository(gitignoreText, rule) + ); +} + +async function postWriteIsValid(plan: IOSRuntimeKeyPlan, publishableKey: string): Promise { + if (!plan.localSecretsPath || !plan.gitignoreRule) return false; + const localSecretsPath = resolve(plan.root, plan.localSecretsPath); + if (!(await pathIsSafelyWithinIOSRoot(plan.root, localSecretsPath))) return false; + const snapshot = await snapshotExistingFile(localSecretsPath, MAX_LOCAL_SECRETS_BYTES); + const plist = snapshot?.bytes ? parseXMLPlist(snapshot.bytes) : undefined; + const installedKey = + plist && typeof plist[SECRET_KEY] === "string" + ? validatePublishableKey(plist[SECRET_KEY])?.value + : undefined; + if (installedKey !== publishableKey) return false; + const gitBoundary = await coherentGitContext(plan.root, [ + resolve(plan.root, plan.projectPath), + dirname(localSecretsPath), + ]); + if (gitBoundary.state === "unknown" || gitBoundary.state === "mismatch") return false; + if (!(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule))) { + return false; + } + const inspection = await inspectIOSProject(plan.root, { target: plan.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.projectPath !== plan.projectPath || + inspection.selection.targetId !== plan.targetId || + inspection.generatedProject != null || + inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) + ) { + return false; + } + if (await generatedProjectKind(plan.root, resolve(plan.root, plan.projectPath))) return false; + const selectedTarget = inspection.appTargets.find( + (target) => target.id === plan.targetId && target.projectPath === plan.projectPath, + ); + if ( + !hasProvenRuntimeKeyWiring(selectedTarget) || + selectedTarget.runtimeKeySinks[0]?.path !== plan.localSecretsPath + ) { + return false; + } + if ( + !(await hasExclusiveRuntimeSinkOwnership( + plan.root, + plan.projectPath, + plan.targetId, + localSecretsPath, + )) + ) { + return false; + } + const selectedSource = inspection.localPublishableKey.source; + return ( + inspection.localPublishableKey.found && + !inspection.localPublishableKey.conflict && + selectedSource === plan.localSecretsPath + ); +} + +export async function applyIOSRuntimeKey( + plan: IOSRuntimeKeyPlan, + publishableKey: string, + options: IOSRuntimeKeyApplyOptions = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-runtime-key" || + !plan.localSecretsPath || + !plan.gitignorePath || + !plan.gitignoreRule || + !plan.expectedLocalSecretsHash || + plan.expectedGitignoreHash === undefined || + typeof plan.changesGitignore !== "boolean" + ) { + return { + status: "blocked", + plan, + message: "The runtime-key plan is incomplete or unsupported.", + }; + } + + const validatedKey = validatePublishableKey(publishableKey); + if (!validatedKey) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "invalid-publishable-key", + message: "A valid Clerk publishable key is required.", + }, + ], + }, + }; + } + if (validatedKey.instanceType !== "development") { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "production-publishable-key", + message: + "Automatic iOS runtime wiring accepts a development-instance publishable key only.", + }, + ], + }, + }; + } + const normalizedKey = validatedKey.value; + const targetGitignoreRule = plan.gitignoreRule; + + const prepared = await prepareRuntimeKeyPlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + localSecretsPath: plan.localSecretsPath, + }); + if (prepared.plan.status === "blocked") { + return { status: "blocked", plan: prepared.plan }; + } + if ( + prepared.plan.expectedLocalSecretsHash !== plan.expectedLocalSecretsHash || + prepared.plan.expectedGitignoreHash !== plan.expectedGitignoreHash + ) { + return { + status: "stale", + plan, + message: "LocalSecrets.plist or .gitignore changed after the plan was created.", + }; + } + const localSecretsSnapshot = prepared.localSecretsSnapshot!; + const gitignoreSnapshot = prepared.gitignoreSnapshot!; + const plist = prepared.plist!; + const existingKey = existingValidPublishableKey(plist); + if (existingKey && existingKey !== normalizedKey) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "different-publishable-key", + message: + "LocalSecrets.plist already contains a different valid publishable key; it was preserved.", + }, + ], + }, + }; + } + + const needsPlistWrite = existingKey !== normalizedKey || plist[SECRET_KEY] !== normalizedKey; + const needsGitignoreWrite = prepared.gitignoreNeeded === true; + if (!needsPlistWrite && !needsGitignoreWrite) { + return { status: "satisfied", plan }; + } + + const plistCandidate = needsPlistWrite + ? replaceOrInsertPublishableKey(localSecretsSnapshot.bytes!, plist, normalizedKey) + : undefined; + if (needsPlistWrite && !plistCandidate) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + blockers: [ + { + code: "unsupported-local-secrets", + message: + "The publishable-key entry could not be updated without changing unrelated plist data.", + }, + ], + }, + }; + } + + const gitignoreText = gitignoreSnapshot.exists ? decodeUTF8(gitignoreSnapshot.bytes!) : ""; + if (gitignoreText == null) { + return { + status: "blocked", + plan, + message: ".gitignore is not valid UTF-8.", + }; + } + const temporaryRule = needsPlistWrite + ? gitignoreTemporaryRule(plan.root, localSecretsSnapshot.path) + : undefined; + let gitignoreCandidateText = gitignoreText; + if (temporaryRule) { + // This durable guard makes a crash-safe same-filesystem atomic write possible: no key + // bytes are written to the staged plist until Git proves this pattern is effective. + gitignoreCandidateText = appendGitignoreRule(gitignoreCandidateText, temporaryRule); + } + if (needsGitignoreWrite || temporaryRule) { + // Keep the exact target rule last so it is portable even before a repository exists. + gitignoreCandidateText = appendGitignoreRule(gitignoreCandidateText, plan.gitignoreRule); + } + const gitignoreCandidate = + gitignoreCandidateText !== gitignoreText + ? new TextEncoder().encode(gitignoreCandidateText) + : undefined; + const gitignoreCandidateHash = gitignoreCandidate + ? sha256(gitignoreCandidate) + : gitignoreSnapshot.hash; + + const stagedFiles: StagedFile[] = []; + const rollbackDependency: RollbackDependency = { + root: plan.root, + payloadPath: localSecretsSnapshot.path, + protectionPath: gitignoreSnapshot.path, + protectionRules: [...(temporaryRule ? [temporaryRule] : []), targetGitignoreRule], + }; + const gitignoreCandidateIsCurrent = async (): Promise => + gitignoreCandidateHash != null && + (await fileMatchesHash(gitignoreSnapshot.path, MAX_GITIGNORE_BYTES, gitignoreCandidateHash)); + try { + if (gitignoreCandidate) { + stagedFiles.push( + await stageFile(gitignoreSnapshot, gitignoreCandidate, { + cleanupFailures: options.forceGitignoreCommitCleanupFailure === true ? 1 : 0, + }), + ); + } + + if ( + !(await snapshotMatches(localSecretsSnapshot)) || + !(await snapshotMatches(gitignoreSnapshot)) + ) { + return { + status: "stale", + plan, + message: "LocalSecrets.plist or .gitignore changed while the update was being prepared.", + }; + } + + const gitignoreStaged = stagedFiles.find( + (staged) => staged.targetPath === gitignoreSnapshot.path, + ); + if (gitignoreStaged) { + const result = await commitStagedFile(gitignoreStaged); + if (result === "stale") { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: "A target file changed while the runtime-key update was being committed.", + }; + } + } + + if (needsPlistWrite && !(await gitignoreCandidateIsCurrent())) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: ".gitignore changed after the crash-safe guard was committed.", + }; + } + + const localSecretsPath = resolve(plan.root, plan.localSecretsPath); + if (!(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule))) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The Git-ignore safety check failed and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "rolled-back", + plan, + message: "The Git-ignore safety check failed and the original files were restored.", + }; + } + + let plistStaged: StagedFile | undefined; + if (plistCandidate && temporaryRule) { + plistStaged = await stageFile(localSecretsSnapshot, plistCandidate, { + cleanupFailures: options.forcePlistCleanupFailureBeforeCommit === true ? 2 : 0, + forceFailureAfterCreate: options.forcePlistStageFailureAfterCreate === true, + keyBearing: true, + beforeWrite: async (temporaryPath) => { + if (!(await gitignoreCandidateIsCurrent())) return false; + if (!(await localSecretsIsIgnored(plan.root, temporaryPath, temporaryRule))) return false; + await options.beforePlistWrite?.(temporaryPath); + return ( + (await gitignoreCandidateIsCurrent()) && + (await localSecretsIsIgnored(plan.root, temporaryPath, temporaryRule)) && + (await localSecretsIsIgnored(plan.root, localSecretsPath, targetGitignoreRule)) + ); + }, + }); + stagedFiles.push(plistStaged); + await options.afterPlistStage?.(); + if ( + !(await gitignoreCandidateIsCurrent()) || + !(await snapshotMatches(localSecretsSnapshot)) || + !(await localSecretsIsIgnored(plan.root, plistStaged.temporaryPath, temporaryRule)) || + !(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule)) + ) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: "A target file changed while the runtime-key update was being staged.", + }; + } + if (!(await gitignoreCandidateIsCurrent())) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: ".gitignore changed before LocalSecrets.plist was committed.", + }; + } + if ((await commitStagedFile(plistStaged)) === "stale") { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: "A target file changed while the runtime-key update was being committed.", + }; + } + await options.afterPlistCommit?.(); + if (!(await gitignoreCandidateIsCurrent())) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: ".gitignore changed after LocalSecrets.plist was committed.", + }; + } + } + + await options.beforePostWriteValidation?.(); + const valid = + options.forcePostWriteValidationFailure !== true && + (!needsPlistWrite || (await gitignoreCandidateIsCurrent())) && + (await postWriteIsValid(plan, normalizedKey)) && + (!needsPlistWrite || (await gitignoreCandidateIsCurrent())); + if (valid) return { status: "applied", plan }; + + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update failed validation and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "rolled-back", + plan, + message: "The runtime-key update failed validation and the original files were restored.", + }; + } catch (error) { + if ( + !(await rollbackFiles( + stagedFiles, + rollbackDependency, + error instanceof RuntimeKeyTemporaryFileCleanupError && error.keyBearing, + )) + ) { + throw new Error( + "The runtime-key update failed and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + if (error instanceof RuntimeKeyTemporaryFileCleanupError) throw error; + return { + status: "rolled-back", + plan, + message: "The runtime-key update failed and the original files were restored.", + }; + } finally { + await Promise.all(stagedFiles.map(cleanupStagedFile)); + } +} From 04e3da69518e8494182ff0862ee27e9c69c9ac25 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 21:57:16 -0400 Subject: [PATCH 02/29] fix(init): require exclusive entry-source ownership --- .../commands/init/ios/direct-config.test.ts | 121 +++++++++++++++++- .../src/commands/init/ios/direct-config.ts | 73 ++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 16198eb24..ea611c5a3 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -1,5 +1,16 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { + chmod, + link, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,11 +20,13 @@ import { validatePreparedIOSDirectConfig, type IOSDirectConfigBlockerCode, } from "./direct-config.ts"; +import type { PbxObjects } from "./pbx.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const DEVELOPMENT_KEY = `pk_test_${Buffer.from("direct-config.clerk.accounts.dev$").toString("base64")}`; const OTHER_DEVELOPMENT_KEY = `pk_test_${Buffer.from("other-app.clerk.accounts.dev$").toString("base64")}`; const PRODUCTION_KEY = `pk_live_${Buffer.from("production.example.com$").toString("base64")}`; +const SHARED_ENTRY_BUILD_FILE_ID = "454545454545454545454545"; const temporaryDirectories: string[] = []; async function temporaryRoot(prefix = "clerk-ios-direct-config-"): Promise { @@ -48,6 +61,25 @@ async function replaceSource(root: string, value: string | Uint8Array): Promise< await writeFile(appSourcePath(root), value); } +async function updateProject(root: string, update: (objects: PbxObjects) => void): Promise { + const path = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(path, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + update(objects); + await writeFile(path, buildPbxProject(project)); +} + +async function shareEntrySourceWithSecondTarget(root: string): Promise { + await updateProject(root, (objects) => { + const phase = objects[IOS_FIXTURE_IDS.secondSourcesPhase]!; + phase.files = [...(phase.files as string[]), SHARED_ENTRY_BUILD_FILE_ID]; + objects[SHARED_ENTRY_BUILD_FILE_ID] = { + isa: "PBXBuildFile", + fileRef: IOS_FIXTURE_IDS.appFile, + }; + }); +} + function blockerCodes( plan: Awaited>, ): IOSDirectConfigBlockerCode[] { @@ -500,6 +532,93 @@ struct MyApp: App { expect(await readFile(adminPath, "utf8")).toContain("Clerk.configure"); }); + test("refuses an entry source shared with another native target", async () => { + const root = await fixture({ secondTarget: true }); + const before = await readFile(appSourcePath(root)); + await shareEntrySourceWithSecondTarget(root); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("refuses an entry source aliased into another target through a hard link", async () => { + const root = await fixture({ secondTarget: true }); + const aliasPath = join(root, "AdminApp", "SharedApp.swift"); + await link(appSourcePath(root), aliasPath); + await updateProject(root, (objects) => { + objects[IOS_FIXTURE_IDS.secondAppFile]!.path = "SharedApp.swift"; + }); + const sourceInfo = await lstat(appSourcePath(root)); + const aliasInfo = await lstat(aliasPath); + expect(aliasInfo.dev).toBe(sourceInfo.dev); + expect(aliasInfo.ino).toBe(sourceInfo.ino); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + }); + + test("refuses an entry source shared with a project below normal discovery depth", async () => { + const root = await fixture(); + const deepRoot = join(root, "a", "b", "c", "d"); + await createIOSFixture(deepRoot, { clerkSDK: false, includeKey: false }); + await updateProject(deepRoot, (objects) => { + objects[IOS_FIXTURE_IDS.appFile]!.path = "../../../../../MyApp/MyAppApp.swift"; + }); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + }); + + test("fails closed when exhaustive entry-source discovery reaches its safety bound", async () => { + const root = await fixture(); + const beyondBound = Array.from({ length: 26 }, (_, index) => `level-${index}`).reduce( + (directory, component) => join(directory, component), + root, + ); + await mkdir(beyondBound, { recursive: true }); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + }); + + test("replanning refuses newly shared entry-source ownership before writing", async () => { + const root = await fixture({ secondTarget: true }); + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + expect(plan.status).toBe("ready"); + await shareEntrySourceWithSecondTarget(root); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY); + + expect(result.status).toBe("blocked"); + expect(blockerCodes(result.plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("rolls back when another target takes ownership before post-write validation", async () => { + const root = await fixture({ secondTarget: true }); + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + expect(plan.status).toBe("ready"); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY, { + beforePostWriteValidation: async () => shareEntrySourceWithSecondTarget(root), + }); + + expect(result.status).toBe("rolled-back"); + expect(await readFile(appSourcePath(root))).toEqual(before); + expect(blockerCodes(await planIOSDirectConfig(planOptions(root)))).toContain("shared-source"); + }); + test("detects stale source bytes before writing", async () => { const root = await fixture(); const plan = await planIOSDirectConfig(planOptions(root)); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index 7ff3fc759..7dff6af45 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -3,7 +3,7 @@ import { chmod, lstat, open, readFile, rename, rm } from "node:fs/promises"; import { basename, dirname, relative, resolve } from "node:path"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; -import { inspectIOSProject } from "./inspect.ts"; +import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { sanitizeSwiftSource } from "./swift.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -23,6 +23,7 @@ export type IOSDirectConfigBlockerCode = | "generated-project" | "target-not-found" | "incomplete-source-membership" + | "shared-source" | "ambiguous-entry-point" | "unreadable-source" | "unsupported-encoding" @@ -123,6 +124,8 @@ interface FileSnapshot { source: string; hash: string; mode: number; + device: number; + inode: number; } interface Range { @@ -368,12 +371,54 @@ async function sourceSnapshot( source, hash: sha256(bytes), mode: info.mode & 0o7777, + device: info.dev, + inode: info.ino, }; } catch { return undefined; } } +type EntrySourceOwnership = "exclusive" | "shared" | "incomplete"; + +function sourceOwnerKey(projectPath: string, targetId: string): string { + return `${projectPath}\0${targetId}`; +} + +async function entrySourceOwnership( + root: string, + projectPath: string, + targetId: string, + snapshot: FileSnapshot, +): Promise { + const memberships = await inspectIOSSourceMembership(root); + const selectedMembership = memberships.find( + (membership) => membership.projectPath === projectPath && membership.targetId === targetId, + ); + if (!selectedMembership?.complete || memberships.some((membership) => !membership.complete)) { + return "incomplete"; + } + + const owners = new Set(); + try { + for (const membership of memberships) { + let ownsSource = false; + for (const file of membership.files) { + const info = await lstat(file.absolutePath); + if (!info.isFile() || info.isSymbolicLink()) return "incomplete"; + if (info.dev === snapshot.device && info.ino === snapshot.inode) ownsSource = true; + } + if (ownsSource) owners.add(sourceOwnerKey(membership.projectPath, membership.targetId)); + } + } catch { + return "incomplete"; + } + + const selectedOwner = sourceOwnerKey(projectPath, targetId); + if (!owners.has(selectedOwner)) return "incomplete"; + return owners.size === 1 ? "exclusive" : "shared"; +} + async function generatedProjectKind( root: string, absoluteProjectPath: string, @@ -1325,6 +1370,28 @@ async function prepareDirectConfig( expectedSourceHash: snapshot.hash, }); + const ownership = await entrySourceOwnership(root, projectPath, options.targetId, snapshot); + if (ownership === "incomplete") { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete entry-source ownership across every local native target could not be proven.", + { plan: sourcePlan, snapshot }, + ); + } + if (ownership === "shared") { + return blocked( + options, + root, + projectPath, + "shared-source", + "The selected @main Swift source is shared, aliased, or not exclusively owned by the selected target.", + { plan: sourcePlan, snapshot }, + ); + } + const parsed = parseAppStructure(snapshot.source); if ("blocker" in parsed) { return blocked(options, root, projectPath, parsed.blocker.code, parsed.blocker.message, { @@ -1479,7 +1546,7 @@ function redactedKeyBlocker( } function mutationWithHiddenBytes( - snapshot: FileSnapshot, + snapshot: Pick, candidateBytes: Uint8Array, ): IOSDirectConfigFileMutation { const mutation = { @@ -1708,9 +1775,7 @@ async function rollbackStagedSource(staged: StagedSource): Promise { const rollbackMutation = mutationWithHiddenBytes( { absolutePath: staged.mutation.absolutePath, - relativePath: "", bytes: staged.mutation.candidateBytes, - source: "", hash: staged.mutation.candidateHash, mode: staged.mutation.mode, }, From 37c5d13c38cee5a72e1b22d18b735bcef48aca26 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 22:04:05 -0400 Subject: [PATCH 03/29] fix(init): prove iOS mutation ownership exhaustively --- .../init/ios/associated-domain.test.ts | 34 +++++- .../commands/init/ios/associated-domain.ts | 51 +++++---- .../src/commands/init/ios/discovery.ts | 75 ++++++++++++- .../init/ios/entitlements-settings.test.ts | 29 +++++ .../init/ios/entitlements-settings.ts | 35 +++--- .../src/commands/init/ios/inspect.test.ts | 15 +++ .../src/commands/init/ios/runtime-key.test.ts | 77 ++++++++++--- .../src/commands/init/ios/runtime-key.ts | 101 ++++++++++-------- 8 files changed, 307 insertions(+), 110 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index c726be40b..52c51e756 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, link, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + link, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; @@ -273,9 +283,9 @@ describe("iOS Associated Domains setup", () => { expect(await treeDigest(root)).toEqual(before); }); - test("blocks an entitlements file referenced by a target in another Xcode project", async () => { + test("blocks an entitlements file referenced by a deeply nested Xcode project", async () => { const root = await directFixture(); - const secondaryRoot = join(root, "Secondary"); + const secondaryRoot = join(root, "a", "b", "c", "d"); const secondaryProjectPath = join(secondaryRoot, "MyApp.xcodeproj", "project.pbxproj"); const secondaryTargetId = "919191919191919191919191"; await createIOSFixture(secondaryRoot, { includeKey: false }); @@ -283,7 +293,7 @@ describe("iOS Associated Domains setup", () => { .replaceAll(IOS_FIXTURE_IDS.appTarget, secondaryTargetId) .replaceAll( "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", - "CODE_SIGN_ENTITLEMENTS = ../MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", ); await writeFile(secondaryProjectPath, secondaryProject); @@ -295,6 +305,22 @@ describe("iOS Associated Domains setup", () => { expect(await treeDigest(root)).toEqual(before); }); + test("fails closed when exhaustive project discovery reaches its traversal bound", async () => { + const root = await directFixture(); + let directory = root; + for (let depth = 0; depth < 26; depth += 1) { + directory = join(directory, `level-${depth}`); + await mkdir(directory); + } + const before = await treeDigest(root); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + test("blocks nested selected projects owned by XcodeGen or Tuist", async () => { for (const [marker, contents] of [ ["project.yml", "name: MyApp\n"], diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 871e45b3f..f912dbfbd 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -4,8 +4,7 @@ import { parse as parsePbxProject } from "@bacons/xcode/json"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import { - discoverIOSContainers, - inspectWorkspace, + discoverLocalIOSProjects, pathIsSafelyWithinIOSRoot, relativeIOSPath, } from "./discovery.ts"; @@ -23,14 +22,7 @@ import { type IOSMissingEntitlementsSettingsPlan, } from "./entitlements-settings.ts"; import { inspectIOSProject } from "./inspect.ts"; -import { - asString, - asStringArray, - buildPbxParentIndex, - isRecord, - type PbxObject, - type PbxObjects, -} from "./pbx.ts"; +import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; import type { IOSAppTarget, IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; @@ -368,6 +360,12 @@ function normalizeObjects(value: unknown): PbxObjects | undefined { return objects; } +function exactStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? value + : undefined; +} + async function ownershipIsExclusive( root: string, projectPath: string, @@ -390,37 +388,33 @@ async function ownershipIsExclusive( } const selectedProject = resolve(root, projectPath); - const discovered = await discoverIOSContainers(root); - const projectPaths = new Set([...discovered.projectPaths, selectedProject]); - for (const workspacePath of discovered.workspacePaths) { - const workspace = await inspectWorkspace(root, workspacePath); - for (const localProjectPath of workspace.localProjectPaths) { - projectPaths.add(localProjectPath); - } - } - for (const absoluteProject of [...projectPaths].sort()) { + const inventory = await discoverLocalIOSProjects(root, [selectedProject]); + if (!inventory.complete) return false; + for (const absoluteProject of inventory.projectPaths) { const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > 15_000_000) return false; const bytes = new Uint8Array(await readFile(pbxprojPath)); - if (bytes.byteLength > 15_000_000) return false; const archive = parsePbxProject(new TextDecoder().decode(bytes)); const objects = normalizeObjects(archive.objects); if (!objects) return false; const rootObjectId = asString(archive.rootObject); - const projectObject = - (rootObjectId ? objects[rootObjectId] : undefined) ?? - Object.values(objects).find((object) => object.isa === "PBXProject"); + const projectObject = rootObjectId ? objects[rootObjectId] : undefined; if (projectObject?.isa !== "PBXProject") return false; + const targetIds = exactStringArray(projectObject.targets); + if (!targetIds) return false; const parents = buildPbxParentIndex(objects); const groupRootDirectory = resolve( dirname(absoluteProject), asString(projectObject.projectDirPath) ?? "", ); - for (const targetId of asStringArray(projectObject.targets)) { + for (const targetId of targetIds) { if (absoluteProject === selectedProject && targetId === selectedTargetId) continue; const targetObject = objects[targetId]; - if (targetObject?.isa !== "PBXNativeTarget") continue; + if (!targetObject) return false; + if (targetObject.isa !== "PBXNativeTarget") continue; const diagnostics: IOSDiagnostic[] = []; const configurations = await inspectTargetBuildConfigurations({ root, @@ -433,7 +427,12 @@ async function ownershipIsExclusive( parents, diagnostics, }); - if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return false; + if ( + configurations.length === 0 || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } for (const configuration of configurations) { const resolution = configuration.model.entitlementsPath; if (resolution.state === "unresolved") return false; diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index 57abcc96e..ded5e9023 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -44,9 +44,15 @@ export interface IOSProjectReferenceDiscovery { complete: boolean; } +export interface IOSLocalProjectInventory { + projectPaths: string[]; + /** False when traversal or workspace inspection could have hidden another local project. */ + complete: boolean; +} + export interface IOSContainerDiscoveryOptions { /** - * Traverse deeply enough for strict cross-target source-ownership proofs. + * Traverse deeply enough for strict cross-target 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. */ @@ -564,7 +570,11 @@ export function xcodeSchemeRunnableReferenceAttributes( export async function inspectWorkspace( rootInput: string, workspacePath: string, -): Promise<{ inspection: IOSWorkspaceInspection; localProjectPaths: string[] }> { +): Promise<{ + inspection: IOSWorkspaceInspection; + localProjectPaths: string[]; + complete: boolean; +}> { const root = resolve(rootInput); const contentsPath = resolve(workspacePath, "contents.xcworkspacedata"); let xml = ""; @@ -579,6 +589,7 @@ export async function inspectWorkspace( return { inspection: { path: relativeIOSPath(root, workspacePath), projectPaths: [] }, localProjectPaths: [], + complete: false, }; } @@ -586,26 +597,50 @@ export async function inspectWorkspace( const localProjectPaths = new Set(); const workspaceDirectory = dirname(workspacePath); const groupBases = [workspaceDirectory]; + let complete = true; + let sawWorkspace = false; + let workspaceOpen = false; 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] ?? ""; + const selfClosing = attributes.trimEnd().endsWith("/"); + if (tag === "Workspace") { + if (closing) { + if (!workspaceOpen) complete = false; + workspaceOpen = false; + } else { + if (sawWorkspace || workspaceOpen) complete = false; + sawWorkspace = true; + workspaceOpen = !selfClosing; + } + continue; + } + if (!workspaceOpen) complete = false; if (tag === "Group") { if (closing) { - if (groupBases.length > 1) groupBases.pop(); + if (groupBases.length > 1) { + groupBases.pop(); + } else { + complete = false; + } } else { const base = groupBases.at(-1) ?? workspaceDirectory; groupBases.push( resolveWorkspaceLocation(base, xmlAttribute(attributes, "location"), workspaceDirectory), ); - if (attributes.trimEnd().endsWith("/")) groupBases.pop(); + if (selfClosing) groupBases.pop(); } continue; } if (closing || tag !== "FileRef") continue; const location = xmlAttribute(attributes, "location"); + if (!location) { + complete = false; + continue; + } const base = groupBases.at(-1) ?? workspaceDirectory; const embeddedProject = location === "self:" && workspaceDirectory.endsWith(".xcodeproj") @@ -625,9 +660,41 @@ export async function inspectWorkspace( projectPaths: [...projectPaths].sort(), }, localProjectPaths: [...localProjectPaths].sort(), + complete: complete && sawWorkspace && !workspaceOpen && groupBases.length === 1, }; } +/** + * Builds the exhaustive local Xcode-project inventory required by mutation + * ownership proofs. Callers must fail closed when `complete` is false. + */ +export async function discoverLocalIOSProjects( + rootInput: string, + requiredProjectPaths: readonly string[] = [], +): Promise { + const root = resolve(rootInput); + const containers = await discoverIOSContainers(root, { exhaustive: true }); + const projectPaths = new Set(containers.projectPaths); + let complete = containers.complete; + + for (const workspacePath of containers.workspacePaths) { + const workspace = await inspectWorkspace(root, workspacePath); + complete &&= workspace.complete; + for (const projectPath of workspace.localProjectPaths) projectPaths.add(projectPath); + } + + for (const projectPath of requiredProjectPaths) { + const absoluteProjectPath = resolve(root, projectPath); + if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { + complete = false; + continue; + } + projectPaths.add(absoluteProjectPath); + } + + return { projectPaths: [...projectPaths].sort(), complete }; +} + export function pathIsWithinIOSRoot(rootInput: string, candidate: string): boolean { return isWithinRoot(resolve(rootInput), resolve(candidate)); } diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts index 3e9e01cfd..8cf3223f0 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -384,6 +384,35 @@ describe("missing iOS entitlements build settings", () => { ); }); + test("refuses an entitlements destination referenced by a deeply nested project", async () => { + const root = await makeSynchronizedFixture(); + const secondaryRoot = join(root, "a", "b", "c", "d"); + await createIOSFixture(secondaryRoot, { includeKey: false }); + const secondaryProjectPath = join(secondaryRoot, "MyApp.xcodeproj", "project.pbxproj"); + const secondaryProject = (await readFile(secondaryProjectPath, "utf8")).replaceAll( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", + ); + await writeFile(secondaryProjectPath, secondaryProject); + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "shared-entitlements-destination", + ); + }); + + test("fails closed when exhaustive project discovery reaches its traversal bound", async () => { + const root = await makeSynchronizedFixture(); + let directory = root; + for (let depth = 0; depth < 26; depth += 1) { + directory = join(directory, `level-${depth}`); + await mkdir(directory); + } + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "shared-synchronized-root", + ); + }); + test("refuses a destination represented by a classic reference in another project", async () => { const root = await makeSynchronizedFixture(); await createCrossProjectClassicReference(root); diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts index e062ba683..f21d604e5 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -4,8 +4,7 @@ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import { - discoverIOSContainers, - inspectWorkspace, + discoverLocalIOSProjects, pathIsSafelyWithinIOSRoot, relativeIOSPath, } from "./discovery.ts"; @@ -427,18 +426,9 @@ async function selectedSynchronizedRoot( } } -async function localProjectPaths(root: string, selectedProjectPath: string): Promise { - const containers = await discoverIOSContainers(root); - const paths = new Set([...containers.projectPaths, selectedProjectPath]); - for (const workspacePath of containers.workspacePaths) { - const workspace = await inspectWorkspace(root, workspacePath); - for (const projectPath of workspace.localProjectPaths) paths.add(projectPath); - } - return [...paths].sort(); -} - async function synchronizedRootIsExclusive( root: string, + projectPaths: readonly string[], selectedProjectPath: string, selectedTargetId: string, selectedRoot: SynchronizedRoot, @@ -452,7 +442,7 @@ async function synchronizedRootIsExclusive( } catch { return false; } - for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + for (const absoluteProjectPath of projectPaths) { const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; let archive: unknown; @@ -478,7 +468,8 @@ async function synchronizedRootIsExclusive( ); for (const targetId of targetIds) { const target = objects[targetId]; - if (target?.isa !== "PBXNativeTarget") continue; + if (!target) return false; + if (target.isa !== "PBXNativeTarget") continue; const groupIds = optionalExactStringArray(target.fileSystemSynchronizedGroups); if (!groupIds) return false; for (const groupId of groupIds) { @@ -623,7 +614,7 @@ async function gitIgnoreState(destination: string): Promise { async function classicDestinationIsUnreferenced( root: string, - selectedProjectPath: string, + projectPaths: readonly string[], destination: string, ): Promise { const normalizedDestination = resolve(destination).toLocaleLowerCase("en-US"); @@ -635,7 +626,7 @@ async function classicDestinationIsUnreferenced( } catch { return false; } - for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + for (const absoluteProjectPath of projectPaths) { const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; let archive: unknown; @@ -687,12 +678,13 @@ async function classicDestinationIsUnreferenced( async function entitlementsDestinationIsExclusive( root: string, + projectPaths: readonly string[], selectedProjectPath: string, selectedTargetId: string, destination: string, ): Promise { const normalizedDestination = resolve(destination).toLocaleLowerCase("en-US"); - for (const absoluteProjectPath of await localProjectPaths(root, selectedProjectPath)) { + for (const absoluteProjectPath of projectPaths) { const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; let archive: unknown; @@ -718,7 +710,8 @@ async function entitlementsDestinationIsExclusive( for (const targetId of targetIds) { if (absoluteProjectPath === selectedProjectPath && targetId === selectedTargetId) continue; const targetObject = objects[targetId]; - if (targetObject?.isa !== "PBXNativeTarget") continue; + if (!targetObject) return false; + if (targetObject.isa !== "PBXNativeTarget") continue; const diagnostics: IOSDiagnostic[] = []; const configurations = await inspectTargetBuildConfigurations({ root, @@ -986,9 +979,12 @@ export async function planIOSMissingEntitlementsSettings( configurationIds: snapshot.graph.configurationIds, }); } + const inventory = await discoverLocalIOSProjects(root, [snapshot.absoluteProjectPath]); if ( + !inventory.complete || !(await synchronizedRootIsExclusive( root, + inventory.projectPaths, snapshot.absoluteProjectPath, options.targetId, synchronized.root, @@ -1054,7 +1050,7 @@ export async function planIOSMissingEntitlementsSettings( if ( !(await classicDestinationIsUnreferenced( root, - snapshot.absoluteProjectPath, + inventory.projectPaths, destination.absolutePath, )) ) { @@ -1083,6 +1079,7 @@ export async function planIOSMissingEntitlementsSettings( if ( !(await entitlementsDestinationIsExclusive( root, + inventory.projectPaths, snapshot.absoluteProjectPath, options.targetId, destination.absolutePath, 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 7925c0c16..55a83a097 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1225,6 +1225,7 @@ let package = Package( const result = await inspectWorkspace(root, workspace); + expect(result.complete).toBe(true); expect(result.inspection.projectPaths).toEqual(["MyApp.xcodeproj"]); expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); }); @@ -1289,6 +1290,7 @@ let package = Package( const result = await inspectWorkspace(root, workspace); + expect(result.complete).toBe(true); expect(result.inspection.projectPaths).toEqual(["MyApp.xcodeproj"]); expect(result.localProjectPaths).toEqual([join(root, "MyApp.xcodeproj")]); }); @@ -1310,6 +1312,7 @@ let package = Package( expect(JSON.parse(output)).toEqual({ inspection: { path: "MyApp.xcworkspace", projectPaths: [] }, localProjectPaths: [], + complete: false, }); }, 10_000); @@ -1323,9 +1326,21 @@ let package = Package( expect(result).toEqual({ inspection: { path: "MyApp.xcworkspace", projectPaths: [] }, localProjectPaths: [], + complete: false, }); }); + test("reports malformed workspace inventory as incomplete", async () => { + const root = await fixture({ workspace: true }); + const workspace = join(root, "MyApp.xcworkspace"); + await Bun.write(join(workspace, "contents.xcworkspacedata"), "not an Xcode workspace\n"); + + const result = await inspectWorkspace(root, workspace); + + expect(result.complete).toBe(false); + expect(result.localProjectPaths).toEqual([]); + }); + test("does not guess when multiple application targets exist", async () => { const root = await fixture({ secondTarget: true }); const inspection = await inspectIOSProject(root); diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts index 1df124209..3d1daae4a 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -140,6 +140,23 @@ async function fixture(key?: string, secondTarget = false): Promise { return root; } +async function shareLocalSecretsWithSecondTarget(root: string, productType: string): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replace( + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, + `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, + ) + .replace( + `productReference = ${IOS_FIXTURE_IDS.secondProduct};\n productType = "com.apple.product-type.application";`, + `productReference = ${IOS_FIXTURE_IDS.secondProduct};\n productType = "${productType}";`, + ), + ); +} + function options(root: string) { return { root, @@ -573,15 +590,7 @@ describe("iOS runtime publishable-key transaction", () => { test("blocks a LocalSecrets resource shared by another iOS application target", async () => { const root = await fixture("pk_test_...", true); - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project.replace( - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, - ), - ); + await shareLocalSecretsWithSecondTarget(root, "com.apple.product-type.application"); const before = await treeDigest(root); const plan = await planIOSRuntimeKey(options(root)); @@ -591,6 +600,40 @@ describe("iOS runtime publishable-key transaction", () => { expect(await treeDigest(root)).toEqual(before); }); + test.each([ + ["app extension", "com.apple.product-type.app-extension"], + ["unit-test bundle", "com.apple.product-type.bundle.unit-test"], + ])("blocks a LocalSecrets resource shared by another %s target", async (_name, productType) => { + const root = await fixture("pk_test_...", true); + await shareLocalSecretsWithSecondTarget(root, productType); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + }); + + test("blocks a LocalSecrets resource owned by a deep project with the same target ID", async () => { + const root = await fixture("pk_test_..."); + const otherRoot = join(root, "a", "b", "c", "d"); + await createIOSFixture(otherRoot, { + complete: false, + includeKey: false, + localSecrets: true, + }); + const otherProjectPath = join(otherRoot, "MyApp.xcodeproj", "project.pbxproj"); + const otherProject = (await Bun.file(otherProjectPath).text()).replace( + `path = LocalSecrets.plist; sourceTree = "";`, + `path = "${join(root, "MyApp", "LocalSecrets.plist")}"; sourceTree = "";`, + ); + await Bun.write(otherProjectPath, otherProject); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + }); + test("allows a sibling target's proven-disjoint external synchronized group", async () => { const root = await fixture("pk_test_...", true); const externalGroup = await mkdtemp(join(tmpdir(), "clerk-ios-external-group-")); @@ -619,14 +662,24 @@ describe("iOS runtime publishable-key transaction", () => { expect(plan.blockers).toEqual([]); }); - test("ignores a missing unrelated project while proving selected-project ownership", async () => { + test("fails closed when a discovered local project is unreadable", async () => { const root = await fixture("pk_test_..."); await mkdir(join(root, "Unrelated.xcodeproj")); const plan = await planIOSRuntimeKey(options(root)); - expect(plan.status).toBe("ready"); - expect(plan.blockers).toEqual([]); + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + }); + + test("fails closed when a discovered workspace is unreadable", async () => { + const root = await fixture("pk_test_..."); + await mkdir(join(root, "Unreadable.xcworkspace")); + + const plan = await planIOSRuntimeKey(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); }); test("blocks an external symlinked resource that aliases the selected sink", async () => { diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts index 7523a5385..cada03e5d 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -15,7 +15,11 @@ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path import { parse as parsePbxProject } from "@bacons/xcode/json"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { inspectIOSProject } from "./inspect.ts"; -import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + discoverLocalIOSProjects, + pathIsSafelyWithinIOSRoot, + relativeIOSPath, +} from "./discovery.ts"; import type { IOSAppTarget } from "./types.ts"; import { asString, @@ -1262,56 +1266,63 @@ async function hasExclusiveRuntimeSinkOwnership( return false; } - const absoluteProjectPath = resolve(root, projectPath); - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); - if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + const selectedProjectPath = resolve(root, projectPath); + const inventory = await discoverLocalIOSProjects(root, [selectedProjectPath]); + if (!inventory.complete) return false; + const owners = new Set(); + const selectedOwner = `${selectedProjectPath}\0${targetId}`; + let selectedTargetFound = false; + for (const absoluteProjectPath of inventory.projectPaths) { + const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; - let archive: unknown; - try { - const info = await lstat(pbxprojPath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; - archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); - } catch { - return false; - } + let archive: unknown; + try { + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; + archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + } catch { + return false; + } - if (!isRecord(archive)) return false; - const objects = normalizedObjects(archive.objects); - const projectObjectId = asString(archive.rootObject); - const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; - if (!objects || projectObject?.isa !== "PBXProject") return false; + if (!isRecord(archive)) return false; + const objects = normalizedObjects(archive.objects); + const projectObjectId = asString(archive.rootObject); + const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; + if (!objects || projectObject?.isa !== "PBXProject") return false; + + const projectTargetIds = exactStringArray(projectObject.targets); + if (!projectTargetIds) return false; + const parents = buildPbxParentIndex(objects); + const projectDirectory = dirname(absoluteProjectPath); + const groupRootDirectory = resolve( + projectDirectory, + asString(projectObject.projectDirPath) ?? "", + ); - const projectTargetIds = exactStringArray(projectObject.targets); - if (!projectTargetIds || !projectTargetIds.includes(targetId)) return false; - const parents = buildPbxParentIndex(objects); - const projectDirectory = dirname(absoluteProjectPath); - const groupRootDirectory = resolve( - projectDirectory, - asString(projectObject.projectDirPath) ?? "", - ); + for (const candidateTargetId of projectTargetIds) { + const target = objects[candidateTargetId]; + if (!target) return false; + if (target.isa !== "PBXNativeTarget") continue; + if (absoluteProjectPath === selectedProjectPath && candidateTargetId === targetId) { + selectedTargetFound = true; + } - const owners = new Set(); - for (const candidateTargetId of projectTargetIds) { - const target = objects[candidateTargetId]; - if (!target) return false; - if (target.isa !== "PBXNativeTarget" || asString(target.productType) !== APP_PRODUCT_TYPE) { - continue; + const ownership = await targetOwnsCanonicalRuntimeSink({ + canonicalSink, + groupRootDirectory, + objects, + parents, + projectDirectory, + sinkIdentity, + target, + targetId: candidateTargetId, + }); + if (ownership == null) return false; + if (ownership) owners.add(`${absoluteProjectPath}\0${candidateTargetId}`); } - - const ownership = await targetOwnsCanonicalRuntimeSink({ - canonicalSink, - groupRootDirectory, - objects, - parents, - projectDirectory, - sinkIdentity, - target, - targetId: candidateTargetId, - }); - if (ownership == null) return false; - if (ownership) owners.add(candidateTargetId); } - return owners.size === 1 && owners.has(targetId); + return selectedTargetFound && owners.size === 1 && owners.has(selectedOwner); } async function prepareRuntimeKeyVerification( From 3333bf8129e7bd34a346a3fdfbf86b0830313fe5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 23:15:02 -0400 Subject: [PATCH 04/29] fix(init): fail closed on ambiguous Swift syntax --- .../commands/init/ios/direct-config.test.ts | 37 +++++++++++++++++++ .../src/commands/init/ios/direct-config.ts | 17 +++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index ea611c5a3..1d4513dcf 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { applyIOSDirectConfig, + hasExactIOSSwiftUIAppContentRoot, planIOSDirectConfig, prepareIOSDirectConfigMutation, validatePreparedIOSDirectConfig, @@ -159,6 +160,42 @@ struct MyApp: App { ); }); + test("fails closed when Swift regex syntax cannot be inspected safely", async () => { + const malformed = await fixture(); + await replaceSource( + malformed, + `import SwiftUI + +let matcher = /Clerk.shared.auth.signInWithApple() + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + + expect((await planIOSDirectConfig(planOptions(malformed))).status).toBe("blocked"); + expect(hasExactIOSSwiftUIAppContentRoot(await source(malformed))).toBe(false); + + const interpolated = await fixture(); + await replaceSource( + interpolated, + `import SwiftUI + +let matcher = #/prefix\\#(value)Clerk.shared.auth.signInWithApple()/# + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + + expect((await planIOSDirectConfig(planOptions(interpolated))).status).toBe("blocked"); + expect(hasExactIOSSwiftUIAppContentRoot(await source(interpolated))).toBe(false); + }); + test("configures a compact pristine app and is byte-idempotent", async () => { const root = await fixture(); const firstPlan = await planIOSDirectConfig(planOptions(root)); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index 7dff6af45..fda3fe1a8 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -4,7 +4,7 @@ import { basename, dirname, relative, resolve } from "node:path"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; -import { sanitizeSwiftSource } from "./swift.ts"; +import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -838,7 +838,9 @@ function windowGroupRoot( * whose direct root is ContentView. */ export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { - const sanitized = sanitizeSwiftSource(source); + const sanitization = sanitizeSwiftSourceWithStatus(source); + if (!sanitization.complete) return false; + const sanitized = sanitization.sanitizedSource; const structuralIndex = buildSwiftStructuralIndex(sanitized); const appType = appTypeRange(sanitized, structuralIndex); if (!appType) return false; @@ -1022,7 +1024,16 @@ function parseAppStructure( }, }; } - const sanitized = sanitizeSwiftSource(source); + const sanitization = sanitizeSwiftSourceWithStatus(source); + if (!sanitization.complete) { + return { + blocker: { + code: "unsupported-app-structure", + message: "The Swift entry source contains syntax that could not be inspected safely.", + }, + }; + } + const sanitized = sanitization.sanitizedSource; const structuralIndex = buildSwiftStructuralIndex(sanitized); const appType = appTypeRange(sanitized, structuralIndex); if (!appType) { From b3fa5e917aa23f0c0cb44a9ff498df59f5f6f828 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 13:40:37 -0400 Subject: [PATCH 05/29] fix(init): preserve concurrent runtime-key edits --- .../src/commands/init/ios/file-transaction.ts | 36 +- .../src/commands/init/ios/runtime-key.test.ts | 109 +++- .../src/commands/init/ios/runtime-key.ts | 591 ++++++++++++++++-- 3 files changed, 640 insertions(+), 96 deletions(-) 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 39badca53..6ccbe2049 100644 --- a/packages/cli-core/src/commands/init/ios/file-transaction.ts +++ b/packages/cli-core/src/commands/init/ios/file-transaction.ts @@ -105,13 +105,15 @@ interface StagedMutation { }; } -interface ClaimedDestination { +/** @internal */ +export interface ClaimedDestination { path: string; present: boolean; identity: FileIdentity; } -interface FileIdentity { +/** @internal */ +export interface FileIdentity { dev: number; ino: number; mode: number; @@ -157,7 +159,8 @@ interface IOSFileTransactionJournal { class IOSFileTransactionStaleError extends Error {} -class IOSFileTransactionOwnershipError extends Error {} +/** @internal */ +export class IOSFileTransactionOwnershipError extends Error {} class IOSFileTransactionUnsafeSetupCleanupError extends Error { constructor(cause: unknown) { @@ -238,7 +241,8 @@ async function fileMatchesHash(path: string, expectedHash: string): Promise { +/** @internal */ +export async function readRegularFileIdentity(path: string): Promise { try { const info = await lstat(path); if (!info.isFile() || info.isSymbolicLink()) return undefined; @@ -248,7 +252,8 @@ async function readRegularFileIdentity(path: string): Promise { try { @@ -263,7 +268,8 @@ async function readRegularFileIdentityAndHash( } } -async function readPathIdentity(path: string): Promise { +/** @internal */ +export async function readPathIdentity(path: string): Promise { try { const info = await lstat(path); return { dev: info.dev, ino: info.ino, mode: info.mode & 0o7777 }; @@ -282,11 +288,13 @@ async function readDirectoryIdentity(path: string): Promise { @@ -1593,7 +1603,8 @@ async function removeClaimedPath( await syncDirectoryStrict(dirname(claim.path)); } -async function restoreClaimWithoutClobber( +/** @internal */ +export async function restoreClaimWithoutClobber( claim: ClaimedDestination, destinationPath: string, ): Promise { @@ -1707,7 +1718,8 @@ async function claimDestination( return { status: "stale" }; } -async function linkOwnedSourceWithoutClobber( +/** @internal */ +export async function linkOwnedSourceWithoutClobber( sourcePath: string, sourceIdentity: FileIdentity, sourceHash: string, diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts index 3d1daae4a..9e3e3f041 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { appendFile, mkdir, mkdtemp, readdir, rename, rm, symlink } from "node:fs/promises"; +import { + appendFile, + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + rename, + rm, + symlink, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import plist from "@expo/plist"; @@ -770,6 +780,66 @@ describe("iOS runtime publishable-key transaction", () => { expect(await Bun.file(join(ignoreRoot, ".gitignore")).text()).toBe(newerIgnore); }); + test("preserves an editor replacement that wins the plist commit boundary", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const replacementPath = join(root, "MyApp", "editor-replacement.plist"); + const replacement = plistSource("editor-owned-placeholder"); + await Bun.write(replacementPath, replacement); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + + const result = await applyIOSRuntimeKey(plan, publishableKey("commit-boundary.clerk.example"), { + beforeStagedCommitInstall: async (targetPath) => { + if (targetPath === plistPath) await rename(replacementPath, targetPath); + }, + }); + + expect(result.status).toBe("stale"); + expect(await Bun.file(plistPath).text()).toBe(replacement); + expect((await lstat(plistPath)).ino).toBe(replacementIdentity.ino); + expect((await lstat(plistPath)).mode & 0o7777).toBe(0o600); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + expect((await readdir(join(root, "MyApp"))).some((name) => name.includes(".clerk-"))).toBe( + false, + ); + }); + + test("preserves an editor replacement that wins the plist rollback boundary", async () => { + const root = await fixture("pk_test_..."); + const plan = await planIOSRuntimeKey(options(root)); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const replacementPath = join(root, "MyApp", "editor-rollback-replacement.plist"); + const replacement = plistSource("newer-editor-value"); + const key = publishableKey("rollback-boundary.clerk.example"); + await Bun.write(replacementPath, replacement); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + + const apply = applyIOSRuntimeKey(plan, key, { + forcePostWriteValidationFailure: true, + beforeStagedRollbackInstall: async (targetPath) => { + if (targetPath === plistPath) await rename(replacementPath, targetPath); + }, + }); + + await expect(apply).rejects.toThrow("Git-ignore protection was retained"); + expect(await Bun.file(plistPath).text()).toBe(replacement); + expect((await lstat(plistPath)).ino).toBe(replacementIdentity.ino); + expect((await lstat(plistPath)).mode & 0o7777).toBe(0o600); + expect(await Bun.file(plistPath).text()).not.toContain(key); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + for (const name of await readdir(join(root, "MyApp"))) { + if (!name.includes(".clerk-") || !(await Bun.file(join(root, "MyApp", name)).exists())) { + continue; + } + expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); + } + }); + test("rolls back every committed file byte-for-byte after validation failure", async () => { const root = await fixture("pk_test_..."); const before = await treeDigest(root); @@ -807,31 +877,36 @@ describe("iOS runtime publishable-key transaction", () => { expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); }); - test("rolls back when a nested gitignore appears before post-write validation", async () => { + test("leaves the public plist untouched when a nested gitignore blocks safe rollback", async () => { const root = await fixture("pk_test_..."); const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const plistBefore = await Bun.file(plistPath).text(); const plan = await planIOSRuntimeKey(options(root)); + const key = publishableKey("concurrent-nested-ignore.clerk.example"); + let committedInode: number | undefined; - const result = await applyIOSRuntimeKey( - plan, - publishableKey("concurrent-nested-ignore.clerk.example"), - { - beforePostWriteValidation: async () => { - await Bun.write( - join(root, "MyApp", ".gitignore"), - "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", - ); - }, + const apply = applyIOSRuntimeKey(plan, key, { + beforePostWriteValidation: async () => { + committedInode = (await lstat(plistPath)).ino; + await Bun.write( + join(root, "MyApp", ".gitignore"), + "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", + ); }, - ); + }); - expect(result.status).toBe("rolled-back"); - expect(await Bun.file(plistPath).text()).toBe(plistBefore); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); + await expect(apply).rejects.toThrow("Git-ignore protection was retained"); + expect(committedInode).toBeDefined(); + expect((await lstat(plistPath)).ino).toBe(committedInode!); + expect(await Bun.file(plistPath).text()).toContain(key); expect(await Bun.file(join(root, "MyApp", ".gitignore")).text()).toContain( "!LocalSecrets.plist", ); + for (const name of await readdir(join(root, "MyApp"))) { + if (!name.includes(".clerk-") || !(await Bun.file(join(root, "MyApp", name)).exists())) { + continue; + } + expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); + } }); test("rolls back when a sibling target concurrently begins owning the runtime sink", async () => { diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts index cada03e5d..d42d7947f 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -1,14 +1,4 @@ -import { - chmod, - link, - lstat, - open, - readFile, - readdir, - realpath, - rename, - rm, -} from "node:fs/promises"; +import { lstat, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; @@ -31,6 +21,20 @@ import { type PbxObjects, } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import { + IOSFileTransactionOwnershipError as RuntimeKeyFileOwnershipError, + fileMatchesIdentityAndHash, + identitiesMatch, + linkOwnedSourceWithoutClobber, + readPathIdentity, + readRegularFileIdentity, + readRegularFileIdentityAndHash, + removeClaimedPath, + restoreClaimWithoutClobber, + sameFile, + type ClaimedDestination as ClaimedFile, + type FileIdentity, +} from "./file-transaction.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const MAX_PBXPROJ_BYTES = 15_000_000; @@ -152,6 +156,12 @@ export interface IOSRuntimeKeyApplyOptions { afterPlistStage?: () => void | Promise; afterPlistCommit?: () => void | Promise; beforePostWriteValidation?: () => void | Promise; + beforeStagedCommitInstall?: (targetPath: string, claimPath: string) => void | Promise; + beforeStagedRollbackInstall?: ( + targetPath: string, + originalSourcePath: string, + candidateClaimPath: string, + ) => void | Promise; } type GitContext = @@ -166,6 +176,7 @@ interface FileSnapshot { hash?: string; mode: number; bytes?: Uint8Array; + identity?: FileIdentity; } interface PreparedRuntimeKeyPlan { @@ -192,6 +203,13 @@ interface StagedFile { committed: boolean; cleanupFailuresRemaining: number; keyBearing: boolean; + temporaryPresent: boolean; + stagedIdentity: FileIdentity; + committedIdentity?: FileIdentity; + claimedOriginal?: ClaimedFile; + recoveryClaims: ClaimedFile[]; + claimPathIsSafe?: (path: string) => boolean | Promise; + rollbackClaimPathIsSafe?: (path: string) => boolean | Promise; } interface RollbackDependency { @@ -202,6 +220,7 @@ interface RollbackDependency { protectionPath: string; /** Rules that protect both the final payload and its crash-safe staging file. */ protectionRules: string[]; + options: IOSRuntimeKeyApplyOptions; } class RuntimeKeyTemporaryFileCleanupError extends Error { @@ -213,11 +232,19 @@ class RuntimeKeyTemporaryFileCleanupError extends Error { } } +class RuntimeKeyClaimProtectionError extends RuntimeKeyFileOwnershipError { + constructor(readonly claimPath: string) { + super("a runtime-key recovery path was not protected by the committed ignore rule"); + } +} + interface StageFileOptions { forceFailureAfterCreate?: boolean; cleanupFailures?: number; keyBearing?: boolean; beforeWrite?: (temporaryPath: string) => boolean | Promise; + claimPathIsSafe?: (path: string) => boolean | Promise; + rollbackClaimPathIsSafe?: (path: string) => boolean | Promise; } function sha256(value: string | Uint8Array): string { @@ -587,20 +614,35 @@ async function targetLocalSecretsPaths( return { paths: [...paths].sort() }; } +function isFileSystemError(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === code + ); +} + async function snapshotExistingFile( path: string, maximumBytes: number, ): Promise { try { + const beforeRead = await readRegularFileIdentity(path); const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) return undefined; + if (!beforeRead || !info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) { + return undefined; + } const bytes = new Uint8Array(await readFile(path)); + const afterRead = await readRegularFileIdentity(path); + if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; return { path, exists: true, hash: sha256(bytes), - mode: info.mode & 0o7777, + mode: afterRead.mode, bytes, + identity: afterRead, }; } catch { return undefined; @@ -615,15 +657,21 @@ async function snapshotOptionalFile( ): Promise { if (!(await pathIsSafelyWithinIOSRoot(root, path))) return undefined; try { + const beforeRead = await readRegularFileIdentity(path); const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) return undefined; + if (!beforeRead || !info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) { + return undefined; + } const bytes = new Uint8Array(await readFile(path)); + const afterRead = await readRegularFileIdentity(path); + if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; return { path, exists: true, hash: sha256(bytes), - mode: info.mode & 0o7777, + mode: afterRead.mode, bytes, + identity: afterRead, }; } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { @@ -772,6 +820,32 @@ async function gitPathExitCode( } } +async function prospectiveGitPathExitCode( + repositoryRoot: string, + args: string[], + absolutePath: string, +): Promise { + let canonicalParent: string; + try { + canonicalParent = await realpath(dirname(absolutePath)); + } catch { + return undefined; + } + const candidate = resolve(canonicalParent, basename(absolutePath)); + const path = relative(repositoryRoot, candidate); + if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return undefined; + try { + const child = Bun.spawn(["git", ...args, "--", path], { + cwd: repositoryRoot, + stdout: "ignore", + stderr: "ignore", + }); + return await child.exited; + } catch { + return undefined; + } +} + function escapeGitignorePath(path: string): string { return path .split("/") @@ -2064,14 +2138,19 @@ function replaceOrInsertPublishableKey( } async function snapshotMatches(snapshot: FileSnapshot): Promise { - try { - const info = await lstat(snapshot.path); - if (!snapshot.exists) return false; - if (!info.isFile() || info.isSymbolicLink()) return false; - return sha256(await readFile(snapshot.path)) === snapshot.hash; - } catch (error) { - return !snapshot.exists && error instanceof Error && "code" in error && error.code === "ENOENT"; + if (!snapshot.exists) { + try { + await lstat(snapshot.path); + return false; + } catch (error) { + return isFileSystemError(error, "ENOENT"); + } } + return ( + snapshot.identity !== undefined && + snapshot.hash !== undefined && + (await fileMatchesIdentityAndHash(snapshot.path, snapshot.identity, snapshot.hash)) + ); } async function fileMatchesHash( @@ -2096,34 +2175,107 @@ async function syncDirectory(path: string): Promise { } } +function runtimeKeySiblingPath(path: string): string { + return resolve(dirname(path), `.${basename(path)}.clerk-${process.pid}-${randomUUID()}.tmp`); +} + +type ClaimDestinationResult = { status: "claimed"; claim: ClaimedFile } | { status: "stale" }; + +async function claimDestination( + staged: StagedFile, + expectedIdentity: FileIdentity, + expectedHash: string, + claimPathIsSafe = staged.claimPathIsSafe, +): Promise { + const claimPath = runtimeKeySiblingPath(staged.targetPath); + if (staged.keyBearing && !(await claimPathIsSafe?.(claimPath))) { + throw new RuntimeKeyClaimProtectionError(claimPath); + } + try { + await rename(staged.targetPath, claimPath); + } catch (error) { + if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; + throw error; + } + + const movedIdentity = await readPathIdentity(claimPath); + if (!movedIdentity) { + throw new RuntimeKeyFileOwnershipError( + "a claimed runtime-key destination could not be identified after it was moved", + ); + } + const claim: ClaimedFile = { path: claimPath, present: true, identity: movedIdentity }; + staged.recoveryClaims.push(claim); + if (staged.keyBearing && !(await claimPathIsSafe?.(claimPath))) { + await restoreClaimWithoutClobber(claim, staged.targetPath); + throw new RuntimeKeyClaimProtectionError(claimPath); + } + const movedExpectedFile = + identitiesMatch(movedIdentity, expectedIdentity) && + (await fileMatchesIdentityAndHash(claimPath, expectedIdentity, expectedHash)); + if (movedExpectedFile) return { status: "claimed", claim }; + + await restoreClaimWithoutClobber(claim, staged.targetPath); + return { status: "stale" }; +} + async function stageFile( snapshot: FileSnapshot, content: Uint8Array, options: StageFileOptions = {}, ): Promise { - const temporaryPath = resolve( - dirname(snapshot.path), - `.${basename(snapshot.path)}.clerk-${process.pid}-${randomUUID()}.tmp`, - ); + const temporaryPath = runtimeKeySiblingPath(snapshot.path); let created = false; + let openedIdentity: FileIdentity | undefined; try { const file = await open(temporaryPath, "wx", snapshot.mode); created = true; try { + const info = await file.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 }; if (options.beforeWrite && !(await options.beforeWrite(temporaryPath))) { throw new Error("temporary path is not safely ignored"); } await file.writeFile(content); if (options.forceFailureAfterCreate) throw new Error("injected staging failure"); + await file.chmod(snapshot.mode); await file.sync(); } finally { await file.close(); } - await chmod(temporaryPath, snapshot.mode); + const stagedIdentity = await readRegularFileIdentity(temporaryPath); + if ( + !openedIdentity || + !stagedIdentity || + !sameFile(stagedIdentity, openedIdentity) || + stagedIdentity.mode !== snapshot.mode || + !(await fileMatchesIdentityAndHash(temporaryPath, stagedIdentity, sha256(content))) + ) { + throw new Error("staged runtime-key file changed before it could be committed"); + } + return { + targetPath: snapshot.path, + temporaryPath, + candidateHash: sha256(content), + original: snapshot, + committed: false, + cleanupFailuresRemaining: options.cleanupFailures ?? 0, + keyBearing: options.keyBearing === true, + temporaryPresent: true, + stagedIdentity, + recoveryClaims: [], + claimPathIsSafe: options.claimPathIsSafe, + rollbackClaimPathIsSafe: options.rollbackClaimPathIsSafe, + }; } catch { if (created) { try { - await rm(temporaryPath, { force: true }); + const currentIdentity = await readRegularFileIdentity(temporaryPath); + if (!openedIdentity || !currentIdentity || !sameFile(currentIdentity, openedIdentity)) { + throw new Error("the staged runtime-key path no longer identified this transaction"); + } + await rm(temporaryPath); } catch { throw new RuntimeKeyTemporaryFileCleanupError( "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", @@ -2133,18 +2285,10 @@ async function stageFile( } throw new Error("The runtime-key update could not be staged safely."); } - return { - targetPath: snapshot.path, - temporaryPath, - candidateHash: sha256(content), - original: snapshot, - committed: false, - cleanupFailuresRemaining: options.cleanupFailures ?? 0, - keyBearing: options.keyBearing === true, - }; } async function removeStagedTemporaryFile(staged: StagedFile): Promise { + if (!staged.temporaryPresent) return; if (staged.cleanupFailuresRemaining > 0) { staged.cleanupFailuresRemaining -= 1; throw new RuntimeKeyTemporaryFileCleanupError( @@ -2153,7 +2297,12 @@ async function removeStagedTemporaryFile(staged: StagedFile): Promise { ); } try { - await rm(staged.temporaryPath, { force: true }); + const identity = await readRegularFileIdentity(staged.temporaryPath); + if (!identity || !sameFile(identity, staged.stagedIdentity)) { + throw new Error("the staged runtime-key path no longer identified this transaction"); + } + await rm(staged.temporaryPath); + staged.temporaryPresent = false; } catch { throw new RuntimeKeyTemporaryFileCleanupError( "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", @@ -2162,22 +2311,104 @@ async function removeStagedTemporaryFile(staged: StagedFile): Promise { } } -async function commitStagedFile(staged: StagedFile): Promise<"written" | "stale"> { +async function committedCandidateMatches(staged: StagedFile): Promise { + const identity = staged.committedIdentity ?? staged.stagedIdentity; + return fileMatchesIdentityAndHash(staged.targetPath, identity, staged.candidateHash); +} + +async function claimedOriginalMatches(staged: StagedFile): Promise { + if (!staged.original.exists) return true; + return ( + staged.claimedOriginal?.present === true && + staged.original.hash !== undefined && + (await fileMatchesIdentityAndHash( + staged.claimedOriginal.path, + staged.claimedOriginal.identity, + staged.original.hash, + )) + ); +} + +async function commitStagedFile( + staged: StagedFile, + options: IOSRuntimeKeyApplyOptions = {}, +): Promise<"written" | "stale"> { if (!(await snapshotMatches(staged.original))) return "stale"; if (staged.original.exists) { - await rename(staged.temporaryPath, staged.targetPath); - staged.committed = true; - } else { + if (!staged.original.identity || !staged.original.hash) return "stale"; + const claimResult = await claimDestination( + staged, + staged.original.identity, + staged.original.hash, + ); + if (claimResult.status === "stale") return "stale"; + staged.claimedOriginal = claimResult.claim; + let installed = false; try { - await link(staged.temporaryPath, staged.targetPath); + if ( + !(await claimedOriginalMatches(staged)) || + !(await fileMatchesIdentityAndHash( + staged.temporaryPath, + staged.stagedIdentity, + staged.candidateHash, + )) + ) { + throw new RuntimeKeyFileOwnershipError( + "a runtime-key transaction file changed before installation", + ); + } + await options.beforeStagedCommitInstall?.(staged.targetPath, staged.claimedOriginal.path); + if (!(await claimedOriginalMatches(staged))) { + throw new RuntimeKeyFileOwnershipError( + "the claimed runtime-key original changed before installation", + ); + } + const installResult = await linkOwnedSourceWithoutClobber( + staged.temporaryPath, + staged.stagedIdentity, + staged.candidateHash, + staged.targetPath, + ); + if (installResult === "occupied") { + await removeClaimedPath(staged.claimedOriginal, { + expectedHash: staged.original.hash, + expectedMode: staged.original.mode, + }); + await syncDirectory(dirname(staged.targetPath)); + return "stale"; + } + installed = true; } catch (error) { - if (error instanceof Error && "code" in error && error.code === "EEXIST") return "stale"; + if (!installed && staged.claimedOriginal.present) { + try { + await restoreClaimWithoutClobber(staged.claimedOriginal, staged.targetPath); + } catch (restoreError) { + throw new RuntimeKeyFileOwnershipError( + "the claimed runtime-key original could not be restored after commit stopped", + { cause: new AggregateError([error, restoreError]) }, + ); + } + } throw error; } - staged.committed = true; - await removeStagedTemporaryFile(staged); + } else { + const installResult = await linkOwnedSourceWithoutClobber( + staged.temporaryPath, + staged.stagedIdentity, + staged.candidateHash, + staged.targetPath, + ); + if (installResult === "occupied") return "stale"; } + staged.committed = true; + staged.committedIdentity = staged.stagedIdentity; await syncDirectory(dirname(staged.targetPath)); + if (!(await committedCandidateMatches(staged))) { + throw new RuntimeKeyFileOwnershipError( + "the committed runtime-key destination changed before it could be verified", + ); + } + await removeStagedTemporaryFile(staged); return "written"; } @@ -2185,24 +2416,163 @@ async function cleanupStagedFile(staged: StagedFile): Promise { await removeStagedTemporaryFile(staged); } -async function restoreCommittedFile(staged: StagedFile): Promise<"restored" | "stale"> { - const current = await snapshotExistingFile(staged.targetPath, Number.MAX_SAFE_INTEGER); - if (!current || current.hash !== staged.candidateHash) return "stale"; +async function releaseClaimedOriginals(stagedFiles: readonly StagedFile[]): Promise { + const withClaims = stagedFiles.filter( + (staged) => staged.committed && staged.claimedOriginal?.present, + ); + const states = await Promise.all( + withClaims.map(async (staged) => + Boolean( + staged.original.hash && + (await committedCandidateMatches(staged)) && + (await claimedOriginalMatches(staged)), + ), + ), + ); + if (!states.every(Boolean)) return false; + for (const staged of withClaims) { + await removeClaimedPath(staged.claimedOriginal!, { + expectedHash: staged.original.hash, + expectedMode: staged.original.mode, + }); + } + return true; +} + +async function discardClaimedOriginal(staged: StagedFile): Promise { + if (!staged.claimedOriginal?.present) return true; + if (!staged.original.hash || !(await claimedOriginalMatches(staged))) return false; + await removeClaimedPath(staged.claimedOriginal, { + expectedHash: staged.original.hash, + expectedMode: staged.original.mode, + }); + staged.committed = false; + return true; +} + +async function restoreCommittedFile( + staged: StagedFile, + options: IOSRuntimeKeyApplyOptions = {}, +): Promise<"restored" | "stale"> { + if (!(await committedCandidateMatches(staged))) return "stale"; + const candidateIdentity = staged.committedIdentity ?? staged.stagedIdentity; + const candidateClaimResult = await claimDestination( + staged, + candidateIdentity, + staged.candidateHash, + staged.rollbackClaimPathIsSafe, + ); + if (candidateClaimResult.status === "stale") return "stale"; + const candidateClaim = candidateClaimResult.claim; if (!staged.original.exists) { - await rm(staged.targetPath); + await removeClaimedPath(candidateClaim, { + expectedHash: staged.candidateHash, + expectedMode: staged.original.mode, + }); await syncDirectory(dirname(staged.targetPath)); staged.committed = false; return "restored"; } - const rollback = await stageFile(current, staged.original.bytes!, { - keyBearing: staged.keyBearing, - }); + + let rollback: StagedFile | undefined; + const originalClaim = staged.claimedOriginal?.present ? staged.claimedOriginal : undefined; + if (!originalClaim) { + rollback = await stageFile( + { + path: staged.targetPath, + exists: false, + mode: staged.original.mode, + }, + staged.original.bytes!, + { + keyBearing: staged.keyBearing, + beforeWrite: staged.claimPathIsSafe, + claimPathIsSafe: staged.claimPathIsSafe, + rollbackClaimPathIsSafe: staged.rollbackClaimPathIsSafe, + }, + ); + } + const originalSourcePath = originalClaim?.path ?? rollback!.temporaryPath; + const originalSource = await readRegularFileIdentityAndHash(originalSourcePath); + if ( + !originalSource || + (originalClaim && !sameFile(originalSource.identity, originalClaim.identity)) || + (rollback && !sameFile(originalSource.identity, rollback.stagedIdentity)) + ) { + throw new RuntimeKeyFileOwnershipError( + "the original runtime-key file could not be identified during rollback", + ); + } + let sourceInstalled = false; try { - if ((await commitStagedFile(rollback)) !== "written") return "stale"; + await options.beforeStagedRollbackInstall?.( + staged.targetPath, + originalSourcePath, + candidateClaim.path, + ); + const installResult = await linkOwnedSourceWithoutClobber( + originalSourcePath, + originalSource.identity, + originalSource.hash, + staged.targetPath, + ); + if (installResult === "occupied") { + await removeClaimedPath(candidateClaim, { + expectedHash: staged.candidateHash, + expectedMode: staged.original.mode, + }); + return "stale"; + } + sourceInstalled = true; + const restoredIdentity = await readRegularFileIdentity(staged.targetPath); + if ( + !restoredIdentity || + !sameFile(restoredIdentity, originalSource.identity) || + !(await fileMatchesIdentityAndHash( + staged.targetPath, + originalSource.identity, + originalSource.hash, + )) + ) { + throw new RuntimeKeyFileOwnershipError( + "the restored runtime-key destination did not match its recovery source", + ); + } + await removeClaimedPath(candidateClaim, { + expectedHash: staged.candidateHash, + expectedMode: staged.original.mode, + }); + if (originalClaim) { + await removeClaimedPath(originalClaim, { + expectedHash: originalSource.hash, + expectedMode: originalSource.identity.mode, + }); + } staged.committed = false; + await syncDirectory(dirname(staged.targetPath)); return "restored"; + } catch (error) { + if (!sourceInstalled) { + try { + const publicIdentity = await readPathIdentity(staged.targetPath); + if (!publicIdentity) { + await restoreClaimWithoutClobber(candidateClaim, staged.targetPath); + } else if (candidateClaim.present) { + await removeClaimedPath(candidateClaim, { + expectedHash: staged.candidateHash, + expectedMode: staged.original.mode, + }); + } + } catch (restoreError) { + throw new RuntimeKeyFileOwnershipError( + "the claimed runtime-key candidate could not be recovered after rollback stopped", + { cause: new AggregateError([error, restoreError]) }, + ); + } + } + throw error; } finally { - await cleanupStagedFile(rollback); + if (rollback) await cleanupStagedFile(rollback); } } @@ -2233,6 +2603,14 @@ async function rollbackFiles( ...(payload ? [payload] : []), ...[...stagedFiles].reverse().filter((staged) => staged !== payload), ]; + const keyBearingPaths = (): string[] => + stagedFiles + .filter((staged) => staged.keyBearing) + .flatMap((staged) => [ + ...(staged.committed ? [staged.targetPath] : []), + ...(staged.temporaryPresent ? [staged.temporaryPath] : []), + ...staged.recoveryClaims.filter((claim) => claim.present).map((claim) => claim.path), + ]); for (const staged of ordered) { if (!staged.committed) continue; if (staged.targetPath === dependency.protectionPath && payloadIsUnsafe) { @@ -2240,13 +2618,26 @@ async function rollbackFiles( continue; } try { - const restoreResult = await restoreCommittedFile(staged); + let restoreResult: "restored" | "stale"; + try { + restoreResult = await restoreCommittedFile(staged, dependency.options); + } catch (error) { + if ( + !(error instanceof RuntimeKeyClaimProtectionError) || + staged.targetPath !== dependency.payloadPath || + !(await ensureRollbackProtection(dependency, keyBearingPaths(), error.claimPath)) + ) { + throw error; + } + restoreResult = await restoreCommittedFile(staged, dependency.options); + } if (restoreResult === "restored") { continue; } if (staged.targetPath === dependency.protectionPath && !payloadIsUnsafe) { // The payload is back to a non-key-bearing state, so retain a concurrent // ignore-file edit instead of overwriting it merely to restore our guard. + if (!(await discardClaimedOriginal(staged))) fullyRestored = false; continue; } } catch (error) { @@ -2261,10 +2652,7 @@ async function rollbackFiles( if (staged.targetPath === dependency.payloadPath) payloadIsUnsafe = true; } if (payloadIsUnsafe) { - const unsafeKeyBearingPaths = stagedFiles - .filter((staged) => staged.keyBearing) - .map((staged) => (staged.committed ? staged.targetPath : staged.temporaryPath)); - if (!(await ensureRollbackProtection(dependency, unsafeKeyBearingPaths))) { + if (!(await ensureRollbackProtection(dependency, keyBearingPaths()))) { fullyRestored = false; } } @@ -2275,8 +2663,9 @@ async function rollbackFiles( async function ensureRollbackProtection( dependency: RollbackDependency, keyBearingPaths: string[], + prospectiveClaimPath?: string, ): Promise { - if ( + const pathsAreProtected = keyBearingPaths.length > 0 && ( await Promise.all( @@ -2290,8 +2679,15 @@ async function ensureRollbackProtection( ), ), ) - ).every(Boolean) - ) { + ).every(Boolean); + const prospectiveClaimIsProtected = + prospectiveClaimPath == null || + (await localSecretsClaimPathIsIgnored( + dependency.root, + prospectiveClaimPath, + dependency.protectionRules[0]!, + )); + if (pathsAreProtected && prospectiveClaimIsProtected) { return true; } @@ -2312,11 +2708,12 @@ async function ensureRollbackProtection( const protection = await stageFile(current, new TextEncoder().encode(protectedText)); try { if ((await commitStagedFile(protection)) !== "written") return false; + if (!(await releaseClaimedOriginals([protection]))) return false; } finally { await cleanupStagedFile(protection); } - return ( + const protectedPaths = ( await Promise.all( keyBearingPaths.map(async (path) => localSecretsIsIgnored( @@ -2329,6 +2726,15 @@ async function ensureRollbackProtection( ), ) ).every(Boolean); + return ( + protectedPaths && + (prospectiveClaimPath == null || + (await localSecretsClaimPathIsIgnored( + dependency.root, + prospectiveClaimPath, + dependency.protectionRules[0]!, + ))) + ); } async function localSecretsIsIgnored( @@ -2361,6 +2767,36 @@ async function localSecretsIsIgnored( ); } +async function localSecretsClaimPathIsIgnored( + root: string, + claimPath: string, + rule: string, +): Promise { + if (await hasDescendantGitignore(root, claimPath)) return false; + const gitignore = await snapshotExistingFile(resolve(root, ".gitignore"), MAX_GITIGNORE_BYTES); + const gitignoreText = gitignore?.bytes ? decodeUTF8(gitignore.bytes) : undefined; + if (gitignoreText == null || !gitignoreContainsRule(gitignoreText, rule)) return false; + const context = await coherentGitContext(root, [dirname(claimPath)]); + if (context.state === "repository") { + const tracked = await prospectiveGitPathExitCode( + context.root, + ["ls-files", "--error-unmatch"], + claimPath, + ); + if (tracked !== 1) return false; + const ignored = await prospectiveGitPathExitCode( + context.root, + ["check-ignore", "--quiet", "--no-index"], + claimPath, + ); + return ignored === 0; + } + return ( + context.state === "not-repository" && + gitignoreRuleIsEffectiveWithoutRepository(gitignoreText, rule) + ); +} + async function postWriteIsValid(plan: IOSRuntimeKeyPlan, publishableKey: string): Promise { if (!plan.localSecretsPath || !plan.gitignoreRule) return false; const localSecretsPath = resolve(plan.root, plan.localSecretsPath); @@ -2577,6 +3013,7 @@ export async function applyIOSRuntimeKey( payloadPath: localSecretsSnapshot.path, protectionPath: gitignoreSnapshot.path, protectionRules: [...(temporaryRule ? [temporaryRule] : []), targetGitignoreRule], + options, }; const gitignoreCandidateIsCurrent = async (): Promise => gitignoreCandidateHash != null && @@ -2605,7 +3042,7 @@ export async function applyIOSRuntimeKey( (staged) => staged.targetPath === gitignoreSnapshot.path, ); if (gitignoreStaged) { - const result = await commitStagedFile(gitignoreStaged); + const result = await commitStagedFile(gitignoreStaged, options); if (result === "stale") { if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { throw new Error( @@ -2653,6 +3090,11 @@ export async function applyIOSRuntimeKey( cleanupFailures: options.forcePlistCleanupFailureBeforeCommit === true ? 2 : 0, forceFailureAfterCreate: options.forcePlistStageFailureAfterCreate === true, keyBearing: true, + claimPathIsSafe: async (claimPath) => + (await gitignoreCandidateIsCurrent()) && + (await localSecretsClaimPathIsIgnored(plan.root, claimPath, temporaryRule)), + rollbackClaimPathIsSafe: async (claimPath) => + localSecretsClaimPathIsIgnored(plan.root, claimPath, temporaryRule), beforeWrite: async (temporaryPath) => { if (!(await gitignoreCandidateIsCurrent())) return false; if (!(await localSecretsIsIgnored(plan.root, temporaryPath, temporaryRule))) return false; @@ -2695,7 +3137,7 @@ export async function applyIOSRuntimeKey( message: ".gitignore changed before LocalSecrets.plist was committed.", }; } - if ((await commitStagedFile(plistStaged)) === "stale") { + if ((await commitStagedFile(plistStaged, options)) === "stale") { if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { throw new Error( "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", @@ -2728,7 +3170,22 @@ export async function applyIOSRuntimeKey( (!needsPlistWrite || (await gitignoreCandidateIsCurrent())) && (await postWriteIsValid(plan, normalizedKey)) && (!needsPlistWrite || (await gitignoreCandidateIsCurrent())); - if (valid) return { status: "applied", plan }; + if (valid) { + const originalsReleased = await releaseClaimedOriginals(stagedFiles); + if (!originalsReleased) { + if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { + throw new Error( + "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", + ); + } + return { + status: "stale", + plan, + message: "A target file changed before the runtime-key update was finalized.", + }; + } + return { status: "applied", plan }; + } if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { throw new Error( From 7a330a89c1712198c01e7ec5f2fcf224ae92a28c Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 13:46:11 -0400 Subject: [PATCH 06/29] fix(init): retain runtime-key recovery guard --- .../src/commands/init/ios/runtime-key.test.ts | 42 +++++++++++++++++++ .../src/commands/init/ios/runtime-key.ts | 4 ++ 2 files changed, 46 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts index 9e3e3f041..73bfcefc3 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -5,6 +5,7 @@ import { lstat, mkdir, mkdtemp, + open, readdir, rename, rm, @@ -806,6 +807,47 @@ describe("iOS runtime publishable-key transaction", () => { ); }); + test("retains ignore protection for a modified recovery claim after commit aborts", async () => { + const root = await fixture("pk_test_..."); + await initGit(root); + const plan = await planIOSRuntimeKey(options(root)); + const plistPath = join(root, "MyApp", "LocalSecrets.plist"); + const replacementPath = join(root, "MyApp", "editor-open-fd-replacement.plist"); + const replacement = plistSource("newer-editor-value"); + const key = publishableKey("commit-recovery-claim.clerk.example"); + await Bun.write(replacementPath, replacement); + const originalHandle = await open(plistPath, "a"); + let recoveryClaimPath: string | undefined; + + const apply = applyIOSRuntimeKey(plan, key, { + beforeStagedCommitInstall: async (targetPath, claimPath) => { + if (targetPath !== plistPath) return; + recoveryClaimPath = claimPath; + await originalHandle.appendFile("\n\n"); + await rename(replacementPath, targetPath); + }, + }); + + try { + await expect(apply).rejects.toThrow("Git-ignore protection was retained"); + } finally { + await originalHandle.close(); + } + expect(recoveryClaimPath).toBeDefined(); + expect(await Bun.file(plistPath).text()).toBe(replacement); + expect(await Bun.file(plistPath).text()).not.toContain(key); + expect(await Bun.file(join(root, ".gitignore")).text()).toBe( + TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, + ); + expect(await Bun.file(recoveryClaimPath!).text()).toContain("open descriptor edit"); + expect(await Bun.file(recoveryClaimPath!).text()).not.toContain(key); + const ignored = Bun.spawn( + ["git", "check-ignore", "--quiet", "--no-index", "--", relative(root, recoveryClaimPath!)], + { cwd: root, stdout: "ignore", stderr: "ignore" }, + ); + expect(await ignored.exited).toBe(0); + }); + test("preserves an editor replacement that wins the plist rollback boundary", async () => { const root = await fixture("pk_test_..."); const plan = await planIOSRuntimeKey(options(root)); diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts index d42d7947f..b4132863c 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -2595,6 +2595,10 @@ async function rollbackFiles( cleanupFailure ??= error; } } + if (staged.keyBearing && staged.recoveryClaims.some((claim) => claim.present)) { + fullyRestored = false; + payloadIsUnsafe = true; + } } const payload = stagedFiles.find( (staged) => staged.committed && staged.targetPath === dependency.payloadPath, From 3fa41bdb43d7815de256c8e949aac585b1f5ba33 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 16:44:49 -0400 Subject: [PATCH 07/29] fix(init): preserve commit-time source replacements --- .../commands/init/ios/direct-config.test.ts | 28 +++ .../src/commands/init/ios/direct-config.ts | 178 +++++------------- 2 files changed, 72 insertions(+), 134 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 1d4513dcf..186941281 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -7,6 +7,8 @@ import { mkdir, mkdtemp, readFile, + readdir, + rename, rm, symlink, writeFile, @@ -62,6 +64,11 @@ async function replaceSource(root: string, value: string | Uint8Array): Promise< await writeFile(appSourcePath(root), value); } +async function expectNoTransactionArtifacts(root: string): Promise { + const names = await readdir(join(root, "MyApp")); + expect(names.filter((name) => name.includes(".clerk-"))).toEqual([]); +} + async function updateProject(root: string, update: (objects: PbxObjects) => void): Promise { const path = join(root, "MyApp.xcodeproj", "project.pbxproj"); const project = parsePbxProject(await readFile(path, "utf8")); @@ -684,6 +691,27 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(raced); }); + test("preserves an editor replacement that wins the commit install boundary", async () => { + const root = await fixture(); + const plan = await planIOSDirectConfig(planOptions(root)); + const replacementPath = join(root, "MyApp", ".MyAppApp.swift.editor-replacement"); + const replacement = Buffer.from("// Newer editor source.\n"); + await writeFile(replacementPath, replacement); + await chmod(replacementPath, 0o600); + const replacementIdentity = await lstat(replacementPath); + + const result = await applyIOSDirectConfig(plan, DEVELOPMENT_KEY, { + beforeCommitInstall: async () => rename(replacementPath, appSourcePath(root)), + }); + + expect(result.status).toBe("stale"); + expect(await readFile(appSourcePath(root))).toEqual(replacement); + const installedIdentity = await lstat(appSourcePath(root)); + expect(installedIdentity.ino).toBe(replacementIdentity.ino); + expect(installedIdentity.mode & 0o7777).toBe(0o600); + await expectNoTransactionArtifacts(root); + }); + test("rolls back an exact candidate after post-write validation fails", async () => { const root = await fixture(); const before = await readFile(appSourcePath(root)); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index fda3fe1a8..f7d85dd54 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -1,8 +1,12 @@ -import { randomUUID } from "node:crypto"; -import { chmod, lstat, open, readFile, rename, rm } from "node:fs/promises"; -import { basename, dirname, relative, resolve } from "node:path"; +import { lstat, readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; import { decodePublishableKey } from "../../../lib/fapi.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSExistingFileTransaction, + IOSFileTransactionError, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; @@ -113,6 +117,7 @@ export type IOSDirectConfigPreparedMutation = /** @internal Test-only fault injection for the standalone atomic writer. */ export interface IOSDirectConfigApplyOptions { beforeCommit?: () => void | Promise; + beforeCommitInstall?: () => void | Promise; beforePostWriteValidation?: () => void | Promise; forcePostWriteValidationFailure?: boolean; } @@ -163,12 +168,6 @@ interface SourceEdit { text: string; } -interface StagedSource { - temporaryPath: string; - mutation: IOSDirectConfigFileMutation; - committed: boolean; -} - const preparedValidators = new WeakMap Promise>(); function sha256(value: string | Uint8Array): string { @@ -1710,98 +1709,6 @@ export async function validatePreparedIOSDirectConfig( return (await preparedValidators.get(prepared)?.()) ?? false; } -async function fileHash(path: string): Promise { - try { - const info = await lstat(path); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SWIFT_FILE_BYTES) { - return undefined; - } - return sha256(await readFile(path)); - } catch { - return undefined; - } -} - -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 where directory fsync is unavailable. - } -} - -async function cleanupTemporarySource(path: string): Promise { - try { - await rm(path, { force: true }); - } catch { - throw new Error( - "A temporary direct iOS source file could not be removed. Inspect the entry-source directory for a .clerk-*.tmp file before retrying.", - ); - } -} - -async function stageSource(mutation: IOSDirectConfigFileMutation): Promise { - const temporaryPath = resolve( - dirname(mutation.absolutePath), - `.${basename(mutation.absolutePath)}.clerk-${process.pid}-${randomUUID()}.tmp`, - ); - let created = false; - try { - const file = await open(temporaryPath, "wx", 0o600); - created = true; - try { - await file.writeFile(mutation.candidateBytes); - await file.sync(); - } finally { - await file.close(); - } - await chmod(temporaryPath, mutation.mode); - return { temporaryPath, mutation, committed: false }; - } catch { - if (created) await cleanupTemporarySource(temporaryPath); - throw new Error("The direct iOS source update could not be staged safely."); - } -} - -async function commitStagedSource(staged: StagedSource): Promise<"written" | "stale"> { - if ((await fileHash(staged.mutation.absolutePath)) !== staged.mutation.expectedHash) { - return "stale"; - } - await rename(staged.temporaryPath, staged.mutation.absolutePath); - staged.committed = true; - await syncDirectory(dirname(staged.mutation.absolutePath)); - return "written"; -} - -async function rollbackStagedSource(staged: StagedSource): Promise { - if (!staged.committed) return true; - if ((await fileHash(staged.mutation.absolutePath)) !== staged.mutation.candidateHash) { - return false; - } - const rollbackMutation = mutationWithHiddenBytes( - { - absolutePath: staged.mutation.absolutePath, - bytes: staged.mutation.candidateBytes, - hash: staged.mutation.candidateHash, - mode: staged.mutation.mode, - }, - staged.mutation.originalBytes, - ); - const rollback = await stageSource(rollbackMutation); - try { - if ((await commitStagedSource(rollback)) !== "written") return false; - staged.committed = false; - return (await fileHash(staged.mutation.absolutePath)) === staged.mutation.expectedHash; - } finally { - await cleanupTemporarySource(rollback.temporaryPath); - } -} - export async function applyIOSDirectConfig( plan: IOSDirectConfigPlan, publishableKey: string, @@ -1810,45 +1717,48 @@ export async function applyIOSDirectConfig( const prepared = await prepareIOSDirectConfigMutation(plan, publishableKey); if (prepared.status !== "ready") return prepared; - const staged = await stageSource(prepared.mutation); + const mutation: IOSExistingFileMutation = { + path: prepared.mutation.absolutePath, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; + let result; try { - await options.beforeCommit?.(); - if ((await commitStagedSource(staged)) === "stale") { - return { - status: "stale", - plan, - message: "The selected Swift entry source changed while the update was being committed.", - }; - } - await options.beforePostWriteValidation?.(); - const valid = - options.forcePostWriteValidationFailure !== true && - (await validatePreparedIOSDirectConfig(prepared)); - if (valid) return { status: "applied", plan }; - - if (!(await rollbackStagedSource(staged))) { - throw new Error( - "The direct iOS source update failed validation, and a concurrent edit prevented safe rollback. Inspect the entry source before retrying.", - ); - } - return { - status: "rolled-back", - plan, - message: "The direct iOS source update failed validation and the original file was restored.", - }; + result = await applyIOSExistingFileTransaction( + [mutation], + [ + async () => { + await options.beforePostWriteValidation?.(); + return ( + options.forcePostWriteValidationFailure !== true && + (await validatePreparedIOSDirectConfig(prepared)) + ); + }, + ], + { + beforeExistingDestinationClaim: options.beforeCommit, + beforeExistingDestinationInstall: options.beforeCommitInstall, + }, + ); } catch (error) { - if (staged.committed && !(await rollbackStagedSource(staged))) { - throw new Error( - "The direct iOS source update failed, and a concurrent edit prevented safe rollback. Inspect the entry source before retrying.", - ); - } - if (error instanceof Error && error.message.includes("concurrent edit")) throw error; + if (!(error instanceof IOSFileTransactionError) || error.code !== "commit-failed") throw error; return { status: "rolled-back", plan, message: "The direct iOS source update failed and the original file was restored.", }; - } finally { - await cleanupTemporarySource(staged.temporaryPath); } + + if (result.status === "applied") return { status: "applied", plan }; + return { + status: result.status, + plan, + message: + result.status === "stale" + ? "The selected Swift entry source changed while the update was being committed." + : "The direct iOS source update failed validation and the original file was restored.", + }; } From 80123911cb8750db943d4bcce6f1766b754855a5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 16:56:29 -0400 Subject: [PATCH 08/29] fix(init): preserve prepared mutation boundaries --- .../commands/init/ios/associated-domain.test.ts | 2 ++ .../src/commands/init/ios/associated-domain.ts | 14 +++++++++++++- .../src/commands/init/ios/direct-config.test.ts | 1 + .../src/commands/init/ios/direct-config.ts | 16 +++++++++++++++- .../init/ios/entitlements-settings.test.ts | 6 ++++++ .../commands/init/ios/entitlements-settings.ts | 12 ++++++++++-- .../src/commands/init/ios/install-sdk.test.ts | 1 + .../src/commands/init/ios/install-sdk.ts | 16 ++++++++++++++++ 8 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index 52c51e756..6488ee1cd 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -359,6 +359,8 @@ describe("iOS Associated Domains setup", () => { }); expect(plan.expectedDomain).toBeUndefined(); expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("Expected prepared domain mutations."); + expect(prepared.mutations.every((mutation) => mutation.boundary.rootPath === root)).toBe(true); expect(JSON.stringify({ plan, prepared })).not.toContain(KEY); expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); }); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index f912dbfbd..ee72fc704 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -11,6 +11,7 @@ import { import { applyIOSFileTransaction, hashIOSFileBytes, + prepareIOSFileMutationBoundary, type IOSCreateFileMutation, type IOSExistingFileMutation, type IOSFileMutation, @@ -931,11 +932,19 @@ export async function prepareIOSAssociatedDomainMutation( ) { return { status: "blocked", plan }; } + const boundary = await prepareIOSFileMutationBoundary(plan.root, createPath); + if ( + !boundary || + boundary.parentIdentity.device !== expectedParentIdentity.device || + boundary.parentIdentity.inode !== expectedParentIdentity.inode + ) { + return { status: "stale", plan }; + } const candidateBytes = newEntitlementsBytes(expectedDomain); const createMutation: IOSCreateFileMutation = { kind: "create", path: createPath, - expectedParentIdentity: { ...expectedParentIdentity }, + boundary, candidateBytes, candidateHash: hashIOSFileBytes(candidateBytes), mode: 0o644, @@ -964,8 +973,11 @@ export async function prepareIOSAssociatedDomainMutation( const candidateSource = addDomainToXML(inspected.file.source, expectedDomain); if (!candidateSource) return { status: "blocked", plan }; const candidateBytes = bytesWithOptionalBOM(candidateSource, inspected.file.bom); + const boundary = await prepareIOSFileMutationBoundary(plan.root, inspected.file.absolutePath); + if (!boundary) return { status: "stale", plan }; mutations.push({ path: inspected.file.absolutePath, + boundary, originalBytes: inspected.file.bytes, originalHash: inspected.file.hash, candidateBytes, diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 186941281..e15b8dfea 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -821,6 +821,7 @@ struct MyApp: App { expect(prepared.status).toBe("ready"); if (prepared.status !== "ready") throw new Error("Expected a prepared mutation."); + expect(prepared.mutation.boundary.rootPath).toBe(root); expect(prepared.mutation.candidateBytes.toString()).not.toBe(""); expect(new TextDecoder().decode(prepared.mutation.candidateBytes)).toContain(DEVELOPMENT_KEY); expect(JSON.stringify(prepared)).not.toContain(DEVELOPMENT_KEY); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index f7d85dd54..9f903b127 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -5,7 +5,9 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { applyIOSExistingFileTransaction, IOSFileTransactionError, + prepareIOSFileMutationBoundary, type IOSExistingFileMutation, + type IOSFileMutationBoundary, } from "./file-transaction.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; @@ -88,6 +90,7 @@ export interface IOSDirectConfigApplyResult { /** @internal A key-bearing in-memory mutation for a multi-file transaction coordinator. */ export interface IOSDirectConfigFileMutation { absolutePath: string; + boundary: IOSFileMutationBoundary; expectedHash: string; candidateHash: string; mode: number; @@ -1558,6 +1561,7 @@ function redactedKeyBlocker( function mutationWithHiddenBytes( snapshot: Pick, candidateBytes: Uint8Array, + boundary: IOSFileMutationBoundary, ): IOSDirectConfigFileMutation { const mutation = { absolutePath: snapshot.absolutePath, @@ -1566,6 +1570,7 @@ function mutationWithHiddenBytes( mode: snapshot.mode, } as IOSDirectConfigFileMutation; Object.defineProperties(mutation, { + boundary: { value: boundary, enumerable: false }, originalBytes: { value: snapshot.bytes, enumerable: false }, candidateBytes: { value: candidateBytes, enumerable: false }, }); @@ -1696,7 +1701,15 @@ export async function prepareIOSDirectConfigMutation( if (candidateHash === current.snapshot.hash) { return { status: "satisfied", plan }; } - const mutation = mutationWithHiddenBytes(current.snapshot, candidateBytes); + const boundary = await prepareIOSFileMutationBoundary(plan.root, current.snapshot.absolutePath); + if (!boundary) { + return { + status: "stale", + plan, + message: "The selected Swift entry source moved outside its prepared project boundary.", + }; + } + const mutation = mutationWithHiddenBytes(current.snapshot, candidateBytes, boundary); return readyPreparedMutation(plan, mutation, async () => exactPostcondition(plan, normalizedKey, candidateHash), ); @@ -1719,6 +1732,7 @@ export async function applyIOSDirectConfig( const mutation: IOSExistingFileMutation = { path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, originalBytes: prepared.mutation.originalBytes, originalHash: prepared.mutation.expectedHash, candidateBytes: prepared.mutation.candidateBytes, diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts index 8cf3223f0..8d43a2088 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -18,6 +18,7 @@ import { join } from "node:path"; import { applyIOSExistingFileTransaction, hashIOSFileBytes, + prepareIOSFileMutationBoundary, type IOSExistingFileMutation, } from "./file-transaction.ts"; import { @@ -206,6 +207,7 @@ describe("missing iOS entitlements build settings", () => { expect(prepared.status).toBe("ready"); expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); if (prepared.status !== "ready") throw new Error("Expected a prepared mutation."); + expect(prepared.mutation.boundary.rootPath).toBe(root); expect(prepared.mutation.originalBytes).toEqual(before); const result = await applyIOSExistingFileTransaction( @@ -252,6 +254,7 @@ describe("missing iOS entitlements build settings", () => { expect(combined.status).toBe("ready"); if (combined.status !== "ready") throw new Error("Expected a combined mutation."); expect(combined.mutation.path).toBe(sdk.mutation.path); + expect(combined.mutation.boundary).toEqual(sdk.mutation.boundary); expect(combined.mutation.originalHash).toBe(sdk.mutation.originalHash); expect(combined.mutation.candidateHash).not.toBe(sdk.mutation.candidateHash); @@ -502,8 +505,11 @@ describe("missing iOS entitlements build settings", () => { const baseRoot = await makeSynchronizedFixture(); const basePlan = await planIOSMissingEntitlementsSettings(options(baseRoot)); const bytes = new Uint8Array(await readFile(pbxprojPath(baseRoot))); + const boundary = await prepareIOSFileMutationBoundary(baseRoot, pbxprojPath(baseRoot)); + if (!boundary) throw new Error("Expected a safe test mutation boundary."); const invalidBase: IOSExistingFileMutation = { path: join(baseRoot, "Other.xcodeproj", "project.pbxproj"), + boundary, originalBytes: bytes, originalHash: hashIOSFileBytes(bytes), candidateBytes: bytes, diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts index f21d604e5..a00adc3b4 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -8,7 +8,11 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath, } from "./discovery.ts"; -import { hashIOSFileBytes, type IOSExistingFileMutation } from "./file-transaction.ts"; +import { + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; import { inspectIOSProject } from "./inspect.ts"; import { asString, @@ -1330,6 +1334,8 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( } catch { return { status: "stale", plan }; } + const boundary = await prepareIOSFileMutationBoundary(plan.root, pbxprojPath); + if (!boundary) return { status: "stale", plan }; try { await lstat(entitlementsPath); return { status: "stale", plan }; @@ -1359,7 +1365,8 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( if ( resolve(baseMutation.path) !== pbxprojPath || baseMutation.originalHash !== plan.expectedPbxprojHash || - baseMutation.mode !== plan.expectedPbxprojMode + baseMutation.mode !== plan.expectedPbxprojMode || + !isDeepStrictEqual(baseMutation.boundary, boundary) ) { return { status: "stale", plan }; } @@ -1463,6 +1470,7 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( const candidateBytes = new TextEncoder().encode(candidate); return preparedWithHiddenMutation(plan, { path: pbxprojPath, + boundary: baseMutation?.boundary ?? boundary, originalBytes: baseMutation?.originalBytes ?? currentBytes, originalHash: baseMutation?.originalHash ?? plan.expectedPbxprojHash, candidateBytes, diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 6d8a377cb..2b423d1b3 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -401,6 +401,7 @@ describe("iOS Clerk SDK installer", () => { expect(prepared.status).toBe("ready"); expect(await readFile(pbxprojPath(root))).toEqual(before); if (prepared.status !== "ready") throw new Error("Expected a prepared SDK mutation."); + expect(prepared.mutation.boundary.rootPath).toBe(root); expect(await validateIOSSDKInstallPostcondition(prepared.plan)).toBe(false); expect(prepared.mutation.path).toBe(pbxprojPath(root)); expect(JSON.stringify(prepared.plan)).not.toContain("candidateBytes"); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 164987975..01533f6cb 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -8,7 +8,9 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { applyIOSExistingFileTransaction, hashIOSFileBytes, + prepareIOSFileMutationBoundary, type IOSExistingFileMutation, + type IOSFileMutationBoundary, } from "./file-transaction.ts"; import { asString, @@ -111,6 +113,7 @@ interface ProductGraph { interface PreparedInstall { plan: IOSSDKInstallPlan; pbxprojPath?: string; + boundary?: IOSFileMutationBoundary; originalBytes?: Uint8Array; originalHash?: string; candidateBytes?: Uint8Array; @@ -872,8 +875,19 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise Date: Thu, 27 Aug 2026 18:52:06 -0400 Subject: [PATCH 09/29] fix(init): follow proven runtime key wiring --- .../src/commands/init/ios/runtime-key.test.ts | 54 +++++++++++++------ .../src/commands/init/ios/runtime-key.ts | 28 +--------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts index 73bfcefc3..98d153077 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -443,25 +443,45 @@ describe("iOS runtime publishable-key transaction", () => { expect(await treeDigest(root)).toEqual(before); }); - test("blocks every enabled selected-target Run-scheme override", async () => { - for (const schemeKey of [ - publishableKey("same-scheme.clerk.example"), - publishableKey("other-scheme.clerk.example"), - ]) { - const root = await fixture("pk_test_..."); - const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(directory, { recursive: true }); - await Bun.write( - join(directory, "MyApp.xcscheme"), - ``, - ); + test("hands off an empty proven LocalSecrets sink despite a stale Run-scheme key", async () => { + const schemeKey = publishableKey("stale-scheme.clerk.example"); + const localKey = publishableKey("local-secrets.clerk.example"); + const root = await fixture(); + const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(directory, { recursive: true }); + await Bun.write( + join(directory, "MyApp.xcscheme"), + ``, + ); - const plan = await planIOSRuntimeKey(options(root)); + const plan = await planIOSRuntimeKey(options(root)); + const result = await applyIOSRuntimeKey(plan, localKey); - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("scheme-override"); - expect(JSON.stringify(plan)).not.toContain(schemeKey); - } + expect(plan.status).toBe("ready"); + expect(plan.localSecretsPath).toBe("MyApp/LocalSecrets.plist"); + expect(result.status).toBe("applied"); + expect(JSON.stringify({ plan, result })).not.toContain(localKey); + expect(JSON.stringify({ plan, result })).not.toContain(schemeKey); + }); + + test("verifies a proven LocalSecrets key despite a stale Run-scheme key", async () => { + const localKey = publishableKey("local-secrets.clerk.example"); + const schemeKey = publishableKey("stale-scheme.clerk.example"); + const root = await fixture(localKey); + const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(directory, { recursive: true }); + await Bun.write( + join(directory, "MyApp.xcscheme"), + ``, + ); + + const plan = await planIOSRuntimeKeyVerification(options(root)); + const result = await verifyIOSRuntimeKey(plan, localKey); + + expect(plan.status).toBe("ready"); + expect(result.status).toBe("matched"); + expect(JSON.stringify({ plan, result })).not.toContain(localKey); + expect(JSON.stringify({ plan, result })).not.toContain(schemeKey); }); test("blocks malformed, binary, oversized, and symlinked sinks", async () => { diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts index b4132863c..ec31d51bc 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -80,7 +80,6 @@ export type IOSRuntimeKeyBlockerCode = | "malformed-local-secrets" | "unsupported-local-secrets" | "unproven-runtime-wiring" - | "scheme-override" | "tracked-local-secrets" | "git-state-unknown" | "git-repository-mismatch" @@ -1458,18 +1457,6 @@ async function prepareRuntimeKeyVerification( "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", ); } - if ( - inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) - ) { - return verificationBlocked( - options, - root, - projectPath, - "scheme-override", - "The selected target has an enabled CLERK_PUBLISHABLE_KEY Run-scheme override, so LocalSecrets.plist is not the exclusive runtime key source.", - ); - } - const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); if (membership.blocker) { return verificationBlocked( @@ -1759,18 +1746,6 @@ async function prepareRuntimeKeyPlan( "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", ); } - if ( - inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) - ) { - return blocked( - options, - root, - projectPath, - "scheme-override", - "The selected target has an enabled CLERK_PUBLISHABLE_KEY Run-scheme override. Disable or remove it before managing LocalSecrets.plist.", - ); - } - const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); if (membership.blocker) { return blocked(options, root, projectPath, membership.blocker.code, membership.blocker.message); @@ -2825,8 +2800,7 @@ async function postWriteIsValid(plan: IOSRuntimeKeyPlan, publishableKey: string) inspection.selection.state !== "selected" || inspection.selection.projectPath !== plan.projectPath || inspection.selection.targetId !== plan.targetId || - inspection.generatedProject != null || - inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme")) + inspection.generatedProject != null ) { return false; } From a548bebc8055e7d23a7d59219d0a235a3a0e00c5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:11:02 -0400 Subject: [PATCH 10/29] fix(init): preserve platform-specific SDK links --- .../src/commands/init/ios/install-sdk.test.ts | 27 +++++++++++++++++++ .../src/commands/init/ios/install-sdk.ts | 15 +++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 2b423d1b3..cbef39af2 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -509,6 +509,33 @@ describe("iOS Clerk SDK installer", () => { ); }); + test("adds an iOS-only ClerkKit link when a multiplatform target already links it on macOS", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + await transformProject(root, (graph) => { + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = graph.objects[configurationId]!.buildSettings as Record; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + } + graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "macos"; + }); + + const plan = await planIOSSDKInstall(installOptions(root)); + expect(plan.status).toBe("ready"); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "applied" }); + + const graph = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + const links = (graph.frameworks.files as string[]) + .map((id) => graph.objects[id]!) + .filter((object) => object.productRef === IOS_FIXTURE_IDS.clerkKit); + expect(links).toHaveLength(2); + expect( + links + .map((object) => object.platformFilter) + .sort((a, b) => String(a).localeCompare(String(b))), + ).toEqual(["ios", "macos"]); + expect((await planIOSSDKInstall(installOptions(root))).status).toBe("satisfied"); + }); + test("reuses a verified local package and canonical remote URL variants", async () => { const localRoot = await fixture(); await mkdir(join(localRoot, "LocalClerk", "Sources", "ClerkKit"), { recursive: true }); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 01533f6cb..42a9e6e37 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -108,6 +108,7 @@ interface ProductGraph { productId?: string; inTarget: boolean; buildFileId?: string; + hasNonIOSBuildFile: boolean; } interface PreparedInstall { @@ -707,6 +708,7 @@ function scanProductGraph( } const phaseMatches: Array<{ buildFileId: string; productId: string }> = []; + let hasNonIOSBuildFile = false; for (const buildFileId of frameworkFiles) { const buildFile = objects[buildFileId]; if (!buildFile || buildFile.isa !== "PBXBuildFile") { @@ -728,7 +730,11 @@ function scanProductGraph( }, }; } - if (applicability.applies) phaseMatches.push({ buildFileId, productId }); + if (applicability.applies) { + phaseMatches.push({ buildFileId, productId }); + } else { + hasNonIOSBuildFile = true; + } } } if (phaseMatches.length > 1) { @@ -765,6 +771,7 @@ function scanProductGraph( productId, inTarget: targetProductId != null, buildFileId: phaseMatch?.buildFileId, + hasNonIOSBuildFile, }, }; } @@ -1256,7 +1263,11 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise Date: Thu, 27 Aug 2026 21:46:13 -0400 Subject: [PATCH 11/29] fix(init): preserve entitlement comments --- .../init/ios/associated-domain.test.ts | 32 +++++++++++++++++++ .../commands/init/ios/associated-domain.ts | 13 +++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index 6488ee1cd..f6609197d 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -190,6 +190,38 @@ describe("iOS Associated Domains setup", () => { expect(updated).toContain(`webcredentials:${HOST}`); }); + test("preserves a comment immediately before a self-closing Associated Domains array", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const compact = + "com.apple.developer.associated-domainswebcredentials:clerk.example.test"; + const existingBlock = [ + "\tcom.apple.developer.associated-domains", + "\t", + "\t", + ].join("\n"); + const source = (await readFile(path, "utf8")).replace(compact, existingBlock); + await writeFile(path, source); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + const expectedBlock = existingBlock.replace( + "\t", + ["\t", `\t\twebcredentials:${HOST}`, "\t"].join("\n"), + ); + const expected = source.replace(existingBlock, expectedBlock); + + expect(plan.status).toBe("ready"); + expect(result.status).toBe("applied"); + expect(await readFile(path, "utf8")).toBe(expected); + + const digest = await treeDigest(root); + const rerun = await planIOSAssociatedDomain(planOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSAssociatedDomain(rerun)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + test("preserves a multiline nonempty array's closing line and indentation", async () => { const root = await directFixture(); const path = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index ee72fc704..12b09a365 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -747,12 +747,15 @@ function addDomainToXML(source: string, expectedDomain: string): string | undefi const afterKey = keyMatch.index + keyMatch[0].length; const tail = structural.slice(afterKey); - const selfClosing = /^\s*]*\/\s*>/.exec(tail); + const selfClosing = /^(\s*)(]*\/\s*>)/.exec(tail); if (selfClosing) { - const start = afterKey + (selfClosing.index ?? 0); - const end = start + selfClosing[0].length; - const keyIndent = lineIndentAt(source, keyMatch.index); - const replacement = `${newline}${keyIndent}${newline}${keyIndent}\t${encoded}${newline}${keyIndent}`; + const leading = selfClosing[1]; + const tag = selfClosing[2]; + if (leading == null || tag == null) return undefined; + const start = afterKey + leading.length; + const end = start + tag.length; + const arrayIndent = lineIndentAt(source, start); + const replacement = `${newline}${arrayIndent}\t${encoded}${newline}${arrayIndent}`; return `${source.slice(0, start)}${replacement}${source.slice(end)}`; } const open = /^\s*]*>/.exec(tail); From a36fa8d56507cdeead334963b42eb1425e43d8ca Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 21:48:05 -0400 Subject: [PATCH 12/29] fix(init): block domains on incomplete key evidence --- .../init/ios/associated-domain.test.ts | 58 +++++++++++++++++++ .../commands/init/ios/associated-domain.ts | 4 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index f6609197d..5fc413127 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -95,6 +95,64 @@ afterEach(async () => { }); describe("iOS Associated Domains setup", () => { + test("blocks a domain when bounded scheme discovery cannot prove the runtime key", async () => { + const root = await temporaryRoot(); + await createIOSFixture(root, { includeKey: false, workspace: true }); + 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 visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + const projectSchemes = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(projectSchemes, { recursive: true }); + await Promise.all( + Array.from({ length: 99 }, (_, index) => + Bun.write( + join(projectSchemes, `A${String(index).padStart(3, "0")}.xcscheme`), + "", + ), + ), + ); + await Bun.write( + join(projectSchemes, "ZRuntime.xcscheme"), + ``, + ); + const workspaceSchemes = join(root, "MyApp.xcworkspace", "xcshareddata", "xcschemes"); + await mkdir(workspaceSchemes, { recursive: true }); + await Bun.write( + join(workspaceSchemes, "WorkspaceRuntime.xcscheme"), + ``, + ); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan).toMatchObject({ + status: "blocked", + requiresPublishableKey: false, + files: [], + blockers: [{ code: "runtime-key-unproven" }], + }); + expect(plan.expectedDomain).toBeUndefined(); + expect((await applyIOSAssociatedDomain(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify(plan)).not.toContain("visible.clerk.example"); + expect(JSON.stringify(plan)).not.toContain("hidden.clerk.example"); + }); + test("creates and attaches an iOS-only entitlements file for a synchronized multiplatform target", async () => { const root = await directFixture(); await convertIOSFixtureToSynchronizedMissingEntitlements(root); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 12b09a365..e508bf724 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -170,7 +170,9 @@ function runtimeFrontendHost( target: IOSAppTarget, ): string | undefined { const key = inspection.localPublishableKey; - if (!key.found || key.conflict || !key.source || !key.frontendApiHost) return undefined; + if (!key.evidenceComplete || !key.found || key.conflict || !key.source || !key.frontendApiHost) { + return undefined; + } const source = key.source; const connected = target.swift.configureCalls.some((call) => { if (call.startupBinding !== "app-init") return false; From f10012e0e327dc80e3a5f9579bc49d832a828824 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 21:58:26 -0400 Subject: [PATCH 13/29] fix(init): inspect all containers before domain changes --- .../init/ios/associated-domain.test.ts | 56 +++++++++++++++++++ .../commands/init/ios/associated-domain.ts | 1 + 2 files changed, 57 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index 5fc413127..78a0f27f0 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -153,6 +153,62 @@ struct MyApp: App { expect(JSON.stringify(plan)).not.toContain("hidden.clerk.example"); }); + test("blocks a domain when an exhaustively discovered workspace scheme conflicts", async () => { + const root = await temporaryRoot(); + await createIOSFixture(root, { includeKey: false }); + 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 visibleKey = `pk_test_${Buffer.from("visible.clerk.example$").toString("base64")}`; + const hiddenKey = `pk_test_${Buffer.from("hidden.clerk.example$").toString("base64")}`; + const projectSchemes = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(projectSchemes, { recursive: true }); + await Bun.write( + join(projectSchemes, "VisibleRuntime.xcscheme"), + ``, + ); + + const workspace = join(root, "One", "Two", "Three", "Four", ".Hidden", "Deep.xcworkspace"); + const workspaceSchemes = join(workspace, "xcshareddata", "xcschemes"); + await mkdir(workspaceSchemes, { recursive: true }); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + await Bun.write( + join(workspaceSchemes, "HiddenRuntime.xcscheme"), + ``, + ); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan).toMatchObject({ + status: "blocked", + requiresPublishableKey: false, + files: [], + blockers: [{ code: "runtime-key-unproven" }], + }); + expect(plan.expectedDomain).toBeUndefined(); + expect((await applyIOSAssociatedDomain(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify(plan)).not.toContain("visible.clerk.example"); + expect(JSON.stringify(plan)).not.toContain("hidden.clerk.example"); + }); + test("creates and attaches an iOS-only entitlements file for a synchronized multiplatform target", async () => { const root = await directFixture(); await convertIOSFixtureToSynchronizedMissingEntitlements(root); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index e508bf724..90e8a0b03 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -499,6 +499,7 @@ export async function planIOSAssociatedDomain( const root = resolve(options.root); const inspection = await inspectIOSProject(root, { target: options.targetId, + exhaustiveContainerDiscovery: true, }); const target = selectedTarget(inspection, options.projectPath, options.targetId); if (!target) { From 3f79405465541bd12c42270e47bc5cde41099e88 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 10:57:39 -0400 Subject: [PATCH 14/29] fix(init): hide prepared SDK mutation bytes --- .../src/commands/init/ios/install-sdk.test.ts | 18 ++++++++++++++++-- .../src/commands/init/ios/install-sdk.ts | 14 ++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index cbef39af2..5d930f400 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -390,7 +390,7 @@ describe("iOS Clerk SDK installer", () => { ]); }); - test("prepares an internal SDK mutation for a combined transaction", async () => { + test("prepares a non-serializable internal SDK mutation for a combined transaction", async () => { const root = await fixture(); await transformProject(root, removeClerkSDK); const before = await readFile(pbxprojPath(root)); @@ -404,7 +404,21 @@ describe("iOS Clerk SDK installer", () => { expect(prepared.mutation.boundary.rootPath).toBe(root); expect(await validateIOSSDKInstallPostcondition(prepared.plan)).toBe(false); expect(prepared.mutation.path).toBe(pbxprojPath(root)); - expect(JSON.stringify(prepared.plan)).not.toContain("candidateBytes"); + expect(Object.getOwnPropertyDescriptor(prepared, "mutation")).toEqual({ + value: prepared.mutation, + enumerable: false, + configurable: false, + writable: false, + }); + const serializedPlan = JSON.stringify(prepared.plan); + const serializedPrepared = JSON.stringify(prepared); + expect(serializedPrepared).toBe(`{"status":"ready","plan":${serializedPlan}}`); + expect(serializedPrepared).not.toContain("mutation"); + expect(serializedPrepared).not.toContain("originalBytes"); + expect(serializedPrepared).not.toContain("candidateBytes"); + expect(serializedPrepared).not.toContain("originalHash"); + expect(serializedPrepared).not.toContain("candidateHash"); + expect(serializedPrepared).not.toContain("boundary"); const result = await applyIOSExistingFileTransaction( [prepared.mutation], diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 42a9e6e37..335ac0bd2 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -1480,10 +1480,12 @@ export async function prepareIOSSDKInstallMutation( }; } - return { - status: "ready", + const result = { + status: "ready" as const, plan: prepared.plan, - mutation: { + } as Extract; + Object.defineProperty(result, "mutation", { + value: { path: prepared.pbxprojPath, boundary: prepared.boundary, originalBytes: prepared.originalBytes, @@ -1492,7 +1494,11 @@ export async function prepareIOSSDKInstallMutation( candidateHash: prepared.candidateHash, mode: prepared.mode, }, - }; + enumerable: false, + configurable: false, + writable: false, + }); + return result; } export async function applyIOSSDKInstall( From 94a925aa94bf57630a6270f59c4b43c8b58d0448 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 11:23:44 -0400 Subject: [PATCH 15/29] refactor(init): keep LocalSecrets compatibility read-only --- .../src/commands/init/ios/runtime-key.test.ts | 1232 +------ .../src/commands/init/ios/runtime-key.ts | 3142 ++--------------- 2 files changed, 299 insertions(+), 4075 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts index 98d153077..8ba3b0624 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts @@ -1,173 +1,40 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { - appendFile, - chmod, - lstat, - mkdir, - mkdtemp, - open, - readdir, - rename, - rm, - symlink, -} from "node:fs/promises"; +import { mkdtemp, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import plist from "@expo/plist"; -import { - applyIOSRuntimeKey, - planIOSRuntimeKey, - planIOSRuntimeKeyVerification, - type IOSRuntimeKeyBlockerCode, - verifyIOSRuntimeKey, -} from "./runtime-key.ts"; +import { join } from "node:path"; +import * as runtimeKey from "./runtime-key.ts"; import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; -const LOADER_FILE = "474747474747474747474747"; -const LOADER_BUILD_FILE = "484848484848484848484848"; -const TARGET_IGNORE_RULE = "/MyApp/LocalSecrets.plist\n"; -const TEMPORARY_IGNORE_RULE = "/MyApp/.LocalSecrets.plist.clerk-*.tmp\n"; function publishableKey(host: string, live = false): string { return `pk_${live ? "live" : "test"}_${Buffer.from(`${host}$`).toString("base64")}`; } -function plistSource(key?: string): string { +function plistSource(key: string): string { return ` - - ANALYTICS_ENABLED - -${key == null ? "" : ` CLERK_PUBLISHABLE_KEY\n ${key}\n`} + CLERK_PUBLISHABLE_KEY + ${key} + `; } -const APP_SOURCE = `import ClerkKit -import SwiftUI - -@main -struct MyApp: App { - init() { - Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") - } - - var body: some Scene { - WindowGroup { Text("Hello") } - .environment(Clerk.shared) - } -} -`; - -const LOADER_SOURCE = `import Foundation - -struct ClerkLocalSecrets { - let publishableKey: String? - - static func load( - bundle: Bundle = .main, - processInfo: ProcessInfo = .processInfo - ) -> ClerkLocalSecrets { - let plistValues = localSecretsPlistValues(bundle: bundle) - return .init( - publishableKey: resolveValue( - for: "CLERK_PUBLISHABLE_KEY", - processInfo: processInfo, - plistValues: plistValues - ) - ) - } - - private static func resolveValue( - for key: String, - processInfo: ProcessInfo, - plistValues: [String: Any] - ) -> String? { - if let environmentValue = normalized(processInfo.environment[key]) { - return environmentValue - } - return normalized(plistValues[key] as? String) - } - - private static func normalized(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value - } - - private static func localSecretsPlistValues(bundle: Bundle) -> [String: Any] { - guard - let url = bundle.url(forResource: "LocalSecrets", withExtension: "plist"), - let data = try? Data(contentsOf: url), - let propertyList = try? PropertyListSerialization.propertyList(from: data, format: nil), - let values = propertyList as? [String: Any] - else { - return [:] - } - return values - } -} -`; - -async function fixture(key?: string, secondTarget = false): Promise { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-key-")); +async function fixture(key: string): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-key-verification-")); temporaryDirectories.push(root); await createIOSFixture(root, { - complete: false, + complete: true, includeKey: false, localSecrets: true, - secondTarget, }); - await Bun.write(join(root, "MyApp", "MyAppApp.swift"), APP_SOURCE); - await Bun.write(join(root, "MyApp", "ClerkLocalSecrets.swift"), LOADER_SOURCE); await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), plistSource(key)); - - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project - .replace( - `children = ( ${IOS_FIXTURE_IDS.appFile}, ${IOS_FIXTURE_IDS.localSecretsFile}, );`, - `children = ( ${IOS_FIXTURE_IDS.appFile}, ${LOADER_FILE}, ${IOS_FIXTURE_IDS.localSecretsFile}, );`, - ) - .replace( - `${IOS_FIXTURE_IDS.appFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; };`, - `${IOS_FIXTURE_IDS.appFile} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; };\n ${LOADER_FILE} = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClerkLocalSecrets.swift; sourceTree = ""; };`, - ) - .replace( - `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, );`, - `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, ${LOADER_BUILD_FILE}, );`, - ) - .replace( - `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IOS_FIXTURE_IDS.appFile}; };`, - `${IOS_FIXTURE_IDS.sourceBuildFile} = { isa = PBXBuildFile; fileRef = ${IOS_FIXTURE_IDS.appFile}; };\n ${LOADER_BUILD_FILE} = { isa = PBXBuildFile; fileRef = ${LOADER_FILE}; };`, - ), - ); return root; } -async function shareLocalSecretsWithSecondTarget(root: string, productType: string): Promise { - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project - .replace( - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, - ) - .replace( - `productReference = ${IOS_FIXTURE_IDS.secondProduct};\n productType = "com.apple.product-type.application";`, - `productReference = ${IOS_FIXTURE_IDS.secondProduct};\n productType = "${productType}";`, - ), - ); -} - function options(root: string) { return { root, @@ -176,1068 +43,115 @@ function options(root: string) { }; } -async function run(root: string, key: string) { - const plan = await planIOSRuntimeKey(options(root)); - const result = await applyIOSRuntimeKey(plan, key); - return { plan, result }; -} - -async function initGit(root: string): Promise { - const child = Bun.spawn(["git", "init", "--quiet"], { - cwd: root, - stdout: "ignore", - stderr: "pipe", - }); - if ((await child.exited) !== 0) { - throw new Error(await new Response(child.stderr).text()); - } -} - afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), ); }); -describe("iOS runtime publishable-key transaction", () => { - test("verifies an existing runtime key without retaining either compared value", async () => { - const localKey = publishableKey("verify-local.clerk.example"); - const linkedKey = publishableKey("verify-linked.clerk.example"); +describe("iOS LocalSecrets compatibility verification", () => { + test("exposes read-only verification without a LocalSecrets mutation API", () => { + expect(Object.keys(runtimeKey).sort()).toEqual([ + "planIOSRuntimeKeyVerification", + "verifyIOSRuntimeKey", + ]); + expect("planIOSRuntimeKey" in runtimeKey).toBe(false); + expect("applyIOSRuntimeKey" in runtimeKey).toBe(false); + }); + + test("compares the exact Quickstart runtime key without retaining or changing it", async () => { + const localKey = publishableKey("local.clerk.example"); + const linkedKey = publishableKey("linked.clerk.example"); const root = await fixture(localKey); - const plan = await planIOSRuntimeKeyVerification(options(root)); + const before = await treeDigest(root); - const matched = await verifyIOSRuntimeKey(plan, localKey); - const mismatched = await verifyIOSRuntimeKey(plan, linkedKey); + const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); + const matched = await runtimeKey.verifyIOSRuntimeKey(plan, localKey); + const mismatched = await runtimeKey.verifyIOSRuntimeKey(plan, linkedKey); expect(plan.status).toBe("ready"); + expect(plan.localSecretsPath).toBe("MyApp/LocalSecrets.plist"); expect(matched.status).toBe("matched"); expect(mismatched.status).toBe("mismatched"); expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(localKey); expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(linkedKey); - }); - - test("treats the same valid key in an ignored target sink as a byte-for-byte no-op", async () => { - const key = publishableKey("same.clerk.example"); - const root = await fixture(key); - await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); - const before = await treeDigest(root); - - const { plan, result } = await run(root, key); - - expect(plan.status).toBe("ready"); - expect(result.status).toBe("satisfied"); - expect(await treeDigest(root)).toEqual(before); - expect(JSON.stringify({ plan, result })).not.toContain(key); - }); - - test("replaces an invalid placeholder while preserving unrelated XML bytes", async () => { - const root = await fixture("pk_test_..."); - const key = publishableKey("replacement.clerk.example"); - const path = join(root, "MyApp", "LocalSecrets.plist"); - const before = await Bun.file(path).text(); - - const { plan, result } = await run(root, key); - const after = await Bun.file(path).text(); - - expect(result.status).toBe("applied"); - expect(after).toBe(before.replace("pk_test_...", key)); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - expect(JSON.stringify({ plan, result })).not.toContain(key); - }); - - test("plans a gitignore change for crash-safe staging even when the target rule exists", async () => { - const root = await fixture("pk_test_..."); - await Bun.write(join(root, ".gitignore"), TARGET_IGNORE_RULE); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("ready"); - expect(plan.changesGitignore).toBe(true); - expect(plan.actions.some((action) => action.includes("atomic-write staging file"))).toBe(true); - }); - - test("inserts a missing key without changing unrelated plist values", async () => { - const root = await fixture(); - const key = publishableKey("insert.clerk.example"); - const path = join(root, "MyApp", "LocalSecrets.plist"); - - const { result } = await run(root, key); - const source = await Bun.file(path).text(); - const parsed = plist.parse(source) as Record; - - expect(result.status).toBe("applied"); - expect(parsed.ANALYTICS_ENABLED).toBe(true); - expect(parsed.CLERK_PUBLISHABLE_KEY).toBe(key); - expect(source).toContain(""); - }); - - test("does not insert a duplicate semantic key when its XML spelling is encoded", async () => { - const root = await fixture("pk_test_..."); - const path = join(root, "MyApp", "LocalSecrets.plist"); - await Bun.write( - path, - plistSource("pk_test_...").replace("CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"), - ); - const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); - - const result = await applyIOSRuntimeKey(plan, publishableKey("encoded-key.clerk.example")); - - expect(result.status).toBe("blocked"); - expect(result.plan.blockers[0]?.code).toBe("unsupported-local-secrets"); - expect(await treeDigest(root)).toEqual(before); - }); - - test("blocks a different valid key without writing any file", async () => { - const existingKey = publishableKey("existing.clerk.example"); - const replacementKey = publishableKey("different.clerk.example"); - const root = await fixture(existingKey); - const before = await treeDigest(root); - - const { plan, result } = await run(root, replacementKey); - - expect(result.status).toBe("blocked"); - expect(result.plan.blockers[0]?.code).toBe("different-publishable-key"); expect(await treeDigest(root)).toEqual(before); - const serialized = JSON.stringify({ plan, result }); - expect(serialized).not.toContain(existingKey); - expect(serialized).not.toContain(replacementKey); - }); - - test("blocks invalid apply input without exposing it", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const invalid = "pk_test_not-a-real-key"; - - const result = await applyIOSRuntimeKey(plan, invalid); - - expect(result.status).toBe("blocked"); - expect(result.plan.blockers[0]?.code).toBe("invalid-publishable-key"); - expect(JSON.stringify(result)).not.toContain(invalid); - }); - - test("blocks a production publishable key without exposing it", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const productionKey = publishableKey("production.clerk.example", true); - - const result = await applyIOSRuntimeKey(plan, productionKey); - - expect(result.status).toBe("blocked"); - expect(result.plan.blockers[0]?.code).toBe("production-publishable-key"); - expect(JSON.stringify(result)).not.toContain(productionKey); - }); - - test("adds only the ignore rule when an existing valid key is not ignored", async () => { - const key = publishableKey("ignore-only.clerk.example"); - const root = await fixture(key); - const plistBefore = await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text(); - - const { result } = await run(root, key); - - expect(result.status).toBe("applied"); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toBe(plistBefore); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe("/MyApp/LocalSecrets.plist\n"); - }); - - test("normalizes surrounding whitespace in an otherwise matching key", async () => { - const key = publishableKey("normalized.clerk.example"); - const root = await fixture(` ${key}\n`); - - const { result } = await run(root, key); - - expect(result.status).toBe("applied"); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toContain( - `${key}`, - ); - }); - - test("adds a portable exact rule even when a broader Git pattern already ignores the sink", async () => { - const root = await fixture("pk_test_..."); - await initGit(root); - await Bun.write(join(root, ".gitignore"), "**/LocalSecrets.plist\n"); - const key = publishableKey("broad-ignore.clerk.example"); - - const { result } = await run(root, key); - - expect(result.status).toBe("applied"); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - `**/LocalSecrets.plist\n${TEMPORARY_IGNORE_RULE}${TARGET_IGNORE_RULE}`, - ); - }); - - test("does not treat whitespace around a rule as the exact portable rule", async () => { - const root = await fixture("pk_test_..."); - await Bun.write(join(root, ".gitignore"), " /MyApp/LocalSecrets.plist\n"); - const key = publishableKey("whitespace-rule.clerk.example"); - - const { result } = await run(root, key); - - expect(result.status).toBe("applied"); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - ` /MyApp/LocalSecrets.plist\n${TEMPORARY_IGNORE_RULE}${TARGET_IGNORE_RULE}`, - ); - }); - - test("appends the exact rule after a later negation before reporting satisfaction", async () => { - for (const repository of [false, true]) { - const key = publishableKey(`${repository ? "git" : "plain"}-negated.clerk.example`); - const root = await fixture(key); - if (repository) await initGit(root); - await Bun.write( - join(root, ".gitignore"), - "/MyApp/LocalSecrets.plist\n!/MyApp/LocalSecrets.plist\n", - ); - - const { result } = await run(root, key); - - expect(result.status).toBe("applied"); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - "/MyApp/LocalSecrets.plist\n!/MyApp/LocalSecrets.plist\n/MyApp/LocalSecrets.plist\n", - ); - if (repository) { - const check = Bun.spawn( - ["git", "check-ignore", "--quiet", "--no-index", "--", "MyApp/LocalSecrets.plist"], - { cwd: root, stdout: "ignore", stderr: "ignore" }, - ); - expect(await check.exited).toBe(0); - } - } - }); - - test("blocks nested gitignore files that can override the root protection", async () => { - for (const repository of [false, true]) { - const root = await fixture("pk_test_..."); - if (repository) await initGit(root); - await Bun.write( - join(root, "MyApp", ".gitignore"), - "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", - ); - const before = await treeDigest(root); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unsafe-gitignore"); - expect(await treeDigest(root)).toEqual(before); - } - }); - - test("blocks a LocalSecrets.plist already tracked by Git", async () => { - const root = await fixture("pk_test_..."); - await initGit(root); - const add = Bun.spawn(["git", "add", "--", "MyApp/LocalSecrets.plist"], { - cwd: root, - stdout: "ignore", - stderr: "ignore", - }); - expect(await add.exited).toBe(0); - const before = await treeDigest(root); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("tracked-local-secrets"); - expect(await treeDigest(root)).toEqual(before); - }); - - test("hands off an empty proven LocalSecrets sink despite a stale Run-scheme key", async () => { - const schemeKey = publishableKey("stale-scheme.clerk.example"); - const localKey = publishableKey("local-secrets.clerk.example"); - const root = await fixture(); - const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(directory, { recursive: true }); - await Bun.write( - join(directory, "MyApp.xcscheme"), - ``, - ); - - const plan = await planIOSRuntimeKey(options(root)); - const result = await applyIOSRuntimeKey(plan, localKey); - - expect(plan.status).toBe("ready"); - expect(plan.localSecretsPath).toBe("MyApp/LocalSecrets.plist"); - expect(result.status).toBe("applied"); - expect(JSON.stringify({ plan, result })).not.toContain(localKey); - expect(JSON.stringify({ plan, result })).not.toContain(schemeKey); - }); - - test("verifies a proven LocalSecrets key despite a stale Run-scheme key", async () => { - const localKey = publishableKey("local-secrets.clerk.example"); - const schemeKey = publishableKey("stale-scheme.clerk.example"); - const root = await fixture(localKey); - const directory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); - await mkdir(directory, { recursive: true }); - await Bun.write( - join(directory, "MyApp.xcscheme"), - ``, - ); - - const plan = await planIOSRuntimeKeyVerification(options(root)); - const result = await verifyIOSRuntimeKey(plan, localKey); - - expect(plan.status).toBe("ready"); - expect(result.status).toBe("matched"); - expect(JSON.stringify({ plan, result })).not.toContain(localKey); - expect(JSON.stringify({ plan, result })).not.toContain(schemeKey); - }); - - test("blocks malformed, binary, oversized, and symlinked sinks", async () => { - const cases: Array<{ - expected: IOSRuntimeKeyBlockerCode; - mutate(root: string): Promise; - }> = [ - { - expected: "malformed-local-secrets", - mutate: async (root) => { - await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); - }, - }, - { - expected: "malformed-local-secrets", - mutate: async (root) => { - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - new Uint8Array([0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30]), - ); - }, - }, - { - expected: "unreadable-local-secrets", - mutate: async (root) => { - await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), "x".repeat(1_000_001)); - }, - }, - { - expected: "unreadable-local-secrets", - mutate: async (root) => { - const path = join(root, "MyApp", "LocalSecrets.plist"); - const outside = join(root, "outside.plist"); - await Bun.write(outside, plistSource("pk_test_...")); - await rm(path); - await symlink(outside, path); - }, - }, - ]; - - for (const item of cases) { - const root = await fixture("pk_test_..."); - await item.mutate(root); - const before = await treeDigest(root); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe(item.expected); - expect(await treeDigest(root)).toEqual(before); - } - }); - - test("blocks a generated project and an explicitly selected non-target resource", async () => { - const generatedRoot = await fixture("pk_test_..."); - await Bun.write(join(generatedRoot, "project.yml"), "name: MyApp\n"); - const generatedPlan = await planIOSRuntimeKey(options(generatedRoot)); - expect(generatedPlan.blockers[0]?.code).toBe("generated-project"); - - const root = await fixture("pk_test_..."); - await mkdir(join(root, "NotTarget")); - await Bun.write(join(root, "NotTarget", "LocalSecrets.plist"), plistSource("pk_test_...")); - const plan = await planIOSRuntimeKey({ - ...options(root), - localSecretsPath: "NotTarget/LocalSecrets.plist", - }); - expect(plan.blockers[0]?.code).toBe("not-target-resource"); - }); - - test("blocks a generator marker beside a nested selected project", async () => { - const root = await fixture("pk_test_..."); - await mkdir(join(root, "ios")); - await rename(join(root, "MyApp.xcodeproj"), join(root, "ios", "MyApp.xcodeproj")); - await rename(join(root, "MyApp"), join(root, "ios", "MyApp")); - await Bun.write(join(root, "ios", "project.yml"), "name: MyApp\n"); - - const plan = await planIOSRuntimeKey({ - root, - projectPath: "ios/MyApp.xcodeproj", - targetId: IOS_FIXTURE_IDS.appTarget, - }); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("generated-project"); - }); - - test("blocks an invocation root above the selected project's nested Git repository", async () => { - const root = await fixture("pk_test_..."); - const nested = join(root, "Nested"); - await mkdir(nested); - await rename(join(root, "MyApp.xcodeproj"), join(nested, "MyApp.xcodeproj")); - await rename(join(root, "MyApp"), join(nested, "MyApp")); - await initGit(nested); - - const plan = await planIOSRuntimeKey({ - root, - projectPath: "Nested/MyApp.xcodeproj", - targetId: IOS_FIXTURE_IDS.appTarget, - }); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("git-repository-mismatch"); expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); }); - test("requires exact entrypoint, configure, loader, and sink proof", async () => { - const root = await fixture("pk_test_..."); - await Bun.write(join(root, "MyApp", "ClerkLocalSecrets.swift"), "import Foundation\n"); + test("reports a changed LocalSecrets file as stale without repairing it", async () => { + const originalKey = publishableKey("original.clerk.example"); + const changedKey = publishableKey("changed.clerk.example"); + const root = await fixture(originalKey); + const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), plistSource(changedKey)); + const changedTree = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); + const result = await runtimeKey.verifyIOSRuntimeKey(plan, changedKey); - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); + expect(result.status).toBe("stale"); + expect(await treeDigest(root)).toEqual(changedTree); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); }); - test("does not treat a same-file unused configure helper as app-startup wiring", async () => { + test("diagnoses an invalid Quickstart placeholder without filling it in", async () => { const root = await fixture("pk_test_..."); - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - APP_SOURCE.replace( - ` init() { - Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") - }`, - ` init() {} - - func unusedConfigureHelper() { - Clerk.configure(publishableKey: ClerkLocalSecrets.load().publishableKey ?? "") - }`, - ), - ); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); - }); - - test("blocks a LocalSecrets resource shared by another iOS application target", async () => { - const root = await fixture("pk_test_...", true); - await shareLocalSecretsWithSecondTarget(root, "com.apple.product-type.application"); const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); + const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); + expect(plan.blockers[0]?.code).toBe("invalid-publishable-key"); expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); }); - test.each([ - ["app extension", "com.apple.product-type.app-extension"], - ["unit-test bundle", "com.apple.product-type.bundle.unit-test"], - ])("blocks a LocalSecrets resource shared by another %s target", async (_name, productType) => { - const root = await fixture("pk_test_...", true); - await shareLocalSecretsWithSecondTarget(root, productType); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - }); - - test("blocks a LocalSecrets resource owned by a deep project with the same target ID", async () => { - const root = await fixture("pk_test_..."); - const otherRoot = join(root, "a", "b", "c", "d"); - await createIOSFixture(otherRoot, { - complete: false, - includeKey: false, - localSecrets: true, - }); - const otherProjectPath = join(otherRoot, "MyApp.xcodeproj", "project.pbxproj"); - const otherProject = (await Bun.file(otherProjectPath).text()).replace( - `path = LocalSecrets.plist; sourceTree = "";`, - `path = "${join(root, "MyApp", "LocalSecrets.plist")}"; sourceTree = "";`, - ); - await Bun.write(otherProjectPath, otherProject); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - }); - - test("allows a sibling target's proven-disjoint external synchronized group", async () => { - const root = await fixture("pk_test_...", true); - const externalGroup = await mkdtemp(join(tmpdir(), "clerk-ios-external-group-")); - temporaryDirectories.push(externalGroup); - await Bun.write(join(externalGroup, "ExternalApp.swift"), "import SwiftUI\n"); - - const synchronizedGroupId = "515151515151515151515151"; - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project - .replace( - `productType = "com.apple.product-type.application";\n packageProductDependencies = ( );`, - `productType = "com.apple.product-type.application";\n fileSystemSynchronizedGroups = ( ${synchronizedGroupId}, );\n packageProductDependencies = ( );`, - ) - .replace( - `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - `${synchronizedGroupId} = { isa = PBXFileSystemSynchronizedRootGroup; path = "${externalGroup}"; sourceTree = ""; };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - ), + test("does not generalize compatibility to a renamed secrets plist", async () => { + const key = publishableKey("renamed.clerk.example"); + const root = await fixture(key); + await rename( + join(root, "MyApp", "LocalSecrets.plist"), + join(root, "MyApp", "ApplicationSecrets.plist"), ); - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("ready"); - expect(plan.blockers).toEqual([]); - }); - - test("fails closed when a discovered local project is unreadable", async () => { - const root = await fixture("pk_test_..."); - await mkdir(join(root, "Unrelated.xcodeproj")); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - }); - - test("fails closed when a discovered workspace is unreadable", async () => { - const root = await fixture("pk_test_..."); - await mkdir(join(root, "Unreadable.xcworkspace")); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - }); - - test("blocks an external symlinked resource that aliases the selected sink", async () => { - const root = await fixture("pk_test_...", true); - const externalGroup = await mkdtemp(join(tmpdir(), "clerk-ios-external-alias-")); - temporaryDirectories.push(externalGroup); - const externalAlias = join(externalGroup, "LocalSecrets.plist"); - await symlink(join(root, "MyApp", "LocalSecrets.plist"), externalAlias); - - const externalReferenceId = "525252525252525252525252"; - const externalBuildFileId = "535353535353535353535353"; - const externalResourcesPhaseId = "545454545454545454545454"; - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = await Bun.file(sourcePath).text(); await Bun.write( - projectPath, - project - .replace( - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${externalResourcesPhaseId}, );`, - ) - .replace( - `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - `${externalReferenceId} = { isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "${externalAlias}"; sourceTree = ""; };\n ${externalBuildFileId} = { isa = PBXBuildFile; fileRef = ${externalReferenceId}; };\n ${externalResourcesPhaseId} = { isa = PBXResourcesBuildPhase; files = ( ${externalBuildFileId}, ); };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - ), + sourcePath, + source.replace('forResource: "LocalSecrets"', 'forResource: "ApplicationSecrets"'), ); - const before = await treeDigest(root); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - expect(await treeDigest(root)).toEqual(before); - }); - test("fails closed when selected-project resource membership is dangling", async () => { - const root = await fixture("pk_test_...", true); - const danglingResourcesPhaseId = "555555555555555555555555"; - const danglingBuildFileId = "565656565656565656565656"; const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); const project = await Bun.file(projectPath).text(); await Bun.write( projectPath, - project - .replace( - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${danglingResourcesPhaseId}, );`, - ) - .replace( - `${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - `${danglingResourcesPhaseId} = { isa = PBXResourcesBuildPhase; files = ( ${danglingBuildFileId}, ); };\n ${IOS_FIXTURE_IDS.projectConfigList} = { isa = XCConfigurationList;`, - ), + project.replace("path = LocalSecrets.plist;", "path = ApplicationSecrets.plist;"), ); const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); + const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("shared-local-secrets"); - expect(await treeDigest(root)).toEqual(before); - }); - - test("rejects stale plist and gitignore plans without overwriting newer bytes", async () => { - const plistRoot = await fixture("pk_test_..."); - const plistPlan = await planIOSRuntimeKey(options(plistRoot)); - const plistPath = join(plistRoot, "MyApp", "LocalSecrets.plist"); - await appendFile(plistPath, "\n\n"); - const newerPlist = await Bun.file(plistPath).text(); - - const plistResult = await applyIOSRuntimeKey( - plistPlan, - publishableKey("stale-plist.clerk.example"), - ); - expect(plistResult.status).toBe("stale"); - expect(await Bun.file(plistPath).text()).toBe(newerPlist); - - const ignoreRoot = await fixture("pk_test_..."); - await Bun.write(join(ignoreRoot, ".gitignore"), "build/\n"); - const ignorePlan = await planIOSRuntimeKey(options(ignoreRoot)); - await appendFile(join(ignoreRoot, ".gitignore"), "DerivedData/\n"); - const newerIgnore = await Bun.file(join(ignoreRoot, ".gitignore")).text(); - - const ignoreResult = await applyIOSRuntimeKey( - ignorePlan, - publishableKey("stale-ignore.clerk.example"), - ); - expect(ignoreResult.status).toBe("stale"); - expect(await Bun.file(join(ignoreRoot, ".gitignore")).text()).toBe(newerIgnore); - }); - - test("preserves an editor replacement that wins the plist commit boundary", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const replacementPath = join(root, "MyApp", "editor-replacement.plist"); - const replacement = plistSource("editor-owned-placeholder"); - await Bun.write(replacementPath, replacement); - await chmod(replacementPath, 0o600); - const replacementIdentity = await lstat(replacementPath); - - const result = await applyIOSRuntimeKey(plan, publishableKey("commit-boundary.clerk.example"), { - beforeStagedCommitInstall: async (targetPath) => { - if (targetPath === plistPath) await rename(replacementPath, targetPath); - }, - }); - - expect(result.status).toBe("stale"); - expect(await Bun.file(plistPath).text()).toBe(replacement); - expect((await lstat(plistPath)).ino).toBe(replacementIdentity.ino); - expect((await lstat(plistPath)).mode & 0o7777).toBe(0o600); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - expect((await readdir(join(root, "MyApp"))).some((name) => name.includes(".clerk-"))).toBe( - false, - ); - }); - - test("retains ignore protection for a modified recovery claim after commit aborts", async () => { - const root = await fixture("pk_test_..."); - await initGit(root); - const plan = await planIOSRuntimeKey(options(root)); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const replacementPath = join(root, "MyApp", "editor-open-fd-replacement.plist"); - const replacement = plistSource("newer-editor-value"); - const key = publishableKey("commit-recovery-claim.clerk.example"); - await Bun.write(replacementPath, replacement); - const originalHandle = await open(plistPath, "a"); - let recoveryClaimPath: string | undefined; - - const apply = applyIOSRuntimeKey(plan, key, { - beforeStagedCommitInstall: async (targetPath, claimPath) => { - if (targetPath !== plistPath) return; - recoveryClaimPath = claimPath; - await originalHandle.appendFile("\n\n"); - await rename(replacementPath, targetPath); - }, - }); - - try { - await expect(apply).rejects.toThrow("Git-ignore protection was retained"); - } finally { - await originalHandle.close(); - } - expect(recoveryClaimPath).toBeDefined(); - expect(await Bun.file(plistPath).text()).toBe(replacement); - expect(await Bun.file(plistPath).text()).not.toContain(key); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - expect(await Bun.file(recoveryClaimPath!).text()).toContain("open descriptor edit"); - expect(await Bun.file(recoveryClaimPath!).text()).not.toContain(key); - const ignored = Bun.spawn( - ["git", "check-ignore", "--quiet", "--no-index", "--", relative(root, recoveryClaimPath!)], - { cwd: root, stdout: "ignore", stderr: "ignore" }, - ); - expect(await ignored.exited).toBe(0); - }); - - test("preserves an editor replacement that wins the plist rollback boundary", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const replacementPath = join(root, "MyApp", "editor-rollback-replacement.plist"); - const replacement = plistSource("newer-editor-value"); - const key = publishableKey("rollback-boundary.clerk.example"); - await Bun.write(replacementPath, replacement); - await chmod(replacementPath, 0o600); - const replacementIdentity = await lstat(replacementPath); - - const apply = applyIOSRuntimeKey(plan, key, { - forcePostWriteValidationFailure: true, - beforeStagedRollbackInstall: async (targetPath) => { - if (targetPath === plistPath) await rename(replacementPath, targetPath); - }, - }); - - await expect(apply).rejects.toThrow("Git-ignore protection was retained"); - expect(await Bun.file(plistPath).text()).toBe(replacement); - expect((await lstat(plistPath)).ino).toBe(replacementIdentity.ino); - expect((await lstat(plistPath)).mode & 0o7777).toBe(0o600); - expect(await Bun.file(plistPath).text()).not.toContain(key); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - for (const name of await readdir(join(root, "MyApp"))) { - if (!name.includes(".clerk-") || !(await Bun.file(join(root, "MyApp", name)).exists())) { - continue; - } - expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); - } - }); - - test("rolls back every committed file byte-for-byte after validation failure", async () => { - const root = await fixture("pk_test_..."); - const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); - - const result = await applyIOSRuntimeKey(plan, publishableKey("rollback.clerk.example"), { - forcePostWriteValidationFailure: true, - }); - - expect(result.status).toBe("rolled-back"); - expect(await treeDigest(root)).toEqual(before); - }); - - test("rolls back when concurrent Swift edits invalidate the proven runtime wiring", async () => { - const root = await fixture("pk_test_..."); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const plistBefore = await Bun.file(plistPath).text(); - const plan = await planIOSRuntimeKey(options(root)); - - const result = await applyIOSRuntimeKey( - plan, - publishableKey("concurrent-swift.clerk.example"), - { - beforePostWriteValidation: async () => { - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - APP_SOURCE.replace("Clerk.configure", "Clerk.notConfigure"), - ); - }, - }, - ); - - expect(result.status).toBe("rolled-back"); - expect(await Bun.file(plistPath).text()).toBe(plistBefore); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - }); - - test("leaves the public plist untouched when a nested gitignore blocks safe rollback", async () => { - const root = await fixture("pk_test_..."); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const plan = await planIOSRuntimeKey(options(root)); - const key = publishableKey("concurrent-nested-ignore.clerk.example"); - let committedInode: number | undefined; - - const apply = applyIOSRuntimeKey(plan, key, { - beforePostWriteValidation: async () => { - committedInode = (await lstat(plistPath)).ino; - await Bun.write( - join(root, "MyApp", ".gitignore"), - "!LocalSecrets.plist\n!.LocalSecrets.plist.clerk-*.tmp\n", - ); - }, - }); - - await expect(apply).rejects.toThrow("Git-ignore protection was retained"); - expect(committedInode).toBeDefined(); - expect((await lstat(plistPath)).ino).toBe(committedInode!); - expect(await Bun.file(plistPath).text()).toContain(key); - expect(await Bun.file(join(root, "MyApp", ".gitignore")).text()).toContain( - "!LocalSecrets.plist", - ); - for (const name of await readdir(join(root, "MyApp"))) { - if (!name.includes(".clerk-") || !(await Bun.file(join(root, "MyApp", name)).exists())) { - continue; - } - expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); - } - }); - - test("rolls back when a sibling target concurrently begins owning the runtime sink", async () => { - const root = await fixture("pk_test_...", true); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const plistBefore = await Bun.file(plistPath).text(); - const plan = await planIOSRuntimeKey(options(root)); - - const result = await applyIOSRuntimeKey( - plan, - publishableKey("concurrent-owner.clerk.example"), - { - beforePostWriteValidation: async () => { - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project.replace( - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, );`, - `buildPhases = ( ${IOS_FIXTURE_IDS.secondSourcesPhase}, ${IOS_FIXTURE_IDS.secondFrameworksPhase}, ${IOS_FIXTURE_IDS.resourcesPhase}, );`, - ), - ); - }, - }, - ); - - expect(result.status).toBe("rolled-back"); - expect(await Bun.file(plistPath).text()).toBe(plistBefore); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - }); - - test("cleans every temporary file when plist staging fails after creation", async () => { - const root = await fixture("pk_test_..."); - const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); - - const result = await applyIOSRuntimeKey(plan, publishableKey("stage-fail.clerk.example"), { - forcePlistStageFailureAfterCreate: true, - }); - - expect(result.status).toBe("rolled-back"); - expect(await treeDigest(root)).toEqual(before); - for (const directory of [root, join(root, "MyApp")]) { - expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); - } - }); - - test("retains the ignore guard when a staged key temp cannot be cleaned before rollback", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const key = publishableKey("stale-temp-cleanup.clerk.example"); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - - const apply = applyIOSRuntimeKey(plan, key, { - forcePlistCleanupFailureBeforeCommit: true, - afterPlistStage: async () => { - await appendFile(plistPath, "\n\n"); - }, - }); - - await expect(apply).rejects.toThrow("temporary runtime-key file could not be removed"); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - expect(await Bun.file(plistPath).text()).not.toContain(key); - for (const directory of [root, join(root, "MyApp")]) { - expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); - } - }); - - test("commits and verifies the temporary-file guard before writing key bytes", async () => { - const root = await fixture("pk_test_..."); - await initGit(root); - const plan = await planIOSRuntimeKey(options(root)); - let guardObserved = false; - - const result = await applyIOSRuntimeKey(plan, publishableKey("guard-first.clerk.example"), { - beforePlistWrite: async (temporaryPath) => { - expect(await Bun.file(temporaryPath).text()).toBe(""); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - const check = Bun.spawn( - ["git", "check-ignore", "--quiet", "--no-index", "--", relative(root, temporaryPath)], - { cwd: root, stdout: "ignore", stderr: "ignore" }, - ); - expect(await check.exited).toBe(0); - guardObserved = true; - }, - }); - - expect(result.status).toBe("applied"); - expect(guardObserved).toBe(true); - }); - - test("never writes key bytes when the committed guard is negated before plist staging", async () => { - const root = await fixture("pk_test_..."); - const key = publishableKey("guard-negated-before-write.clerk.example"); - const plan = await planIOSRuntimeKey(options(root)); - const result = await applyIOSRuntimeKey(plan, key, { - beforePlistWrite: async (temporaryPath) => { - const relativeTemporaryPath = relative(root, temporaryPath).split("\\").join("/"); - await appendFile( - join(root, ".gitignore"), - `!/${relativeTemporaryPath}\n!/MyApp/LocalSecrets.plist\n`, - ); - }, - }); - - expect(["stale", "rolled-back"]).toContain(result.status); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); - for (const name of await readdir(join(root, "MyApp"))) { - if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { - expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); - } - } - }); - - test("rolls back when the committed guard is negated after plist staging", async () => { - const root = await fixture("pk_test_..."); - const key = publishableKey("guard-negated-after-stage.clerk.example"); - const plan = await planIOSRuntimeKey(options(root)); - const result = await applyIOSRuntimeKey(plan, key, { - afterPlistStage: async () => { - const temporaryName = (await readdir(join(root, "MyApp"))).find((name) => - name.includes(".clerk-"), - ); - expect(temporaryName).toBeDefined(); - await appendFile( - join(root, ".gitignore"), - `!/MyApp/${temporaryName}\n!/MyApp/LocalSecrets.plist\n`, - ); - }, - }); - - expect(["stale", "rolled-back"]).toContain(result.status); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); - for (const name of await readdir(join(root, "MyApp"))) { - if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { - expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); - } - } - }); - - test("rolls back when the committed guard is negated after plist commit", async () => { - const root = await fixture("pk_test_..."); - const key = publishableKey("guard-negated-after-commit.clerk.example"); - const plan = await planIOSRuntimeKey(options(root)); - const result = await applyIOSRuntimeKey(plan, key, { - afterPlistCommit: async () => { - await appendFile( - join(root, ".gitignore"), - "!/MyApp/.LocalSecrets.plist.clerk-*.tmp\n!/MyApp/LocalSecrets.plist\n", - ); - }, - }); - - expect(["stale", "rolled-back"]).toContain(result.status); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).not.toContain(key); - for (const name of await readdir(join(root, "MyApp"))) { - if (name.includes(".clerk-") && (await Bun.file(join(root, "MyApp", name)).exists())) { - expect(await Bun.file(join(root, "MyApp", name)).text()).not.toContain(key); - } - } - }); - - test("rolls back a linked target when its staged temporary cleanup fails", async () => { - const root = await fixture("pk_test_..."); - const before = await treeDigest(root); - const plan = await planIOSRuntimeKey(options(root)); - - const apply = applyIOSRuntimeKey(plan, publishableKey("commit-cleanup.clerk.example"), { - forceGitignoreCommitCleanupFailure: true, - }); - - await expect(apply).rejects.toThrow("temporary runtime-key file could not be removed"); - expect(await treeDigest(root)).toEqual(before); - for (const directory of [root, join(root, "MyApp")]) { - expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); - } - }); - - test("retains the exact ignore rule when a newer key-bearing plist prevents rollback", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const key = publishableKey("partial-rollback.clerk.example"); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - - const apply = applyIOSRuntimeKey(plan, key, { - forcePostWriteValidationFailure: true, - beforePostWriteValidation: async () => { - await appendFile(plistPath, "\n\n"); - }, - }); - - await expect(apply).rejects.toThrow("Git-ignore protection was retained"); - expect(await Bun.file(join(root, ".gitignore")).text()).toBe( - TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE, - ); - expect(await Bun.file(plistPath).text()).toContain("concurrent user edit"); - expect(await Bun.file(plistPath).text()).toContain(key); - }); - - test("re-establishes ignore protection when concurrent edits prevent payload rollback", async () => { - const root = await fixture("pk_test_..."); - const plan = await planIOSRuntimeKey(options(root)); - const key = publishableKey("protected-partial-rollback.clerk.example"); - const plistPath = join(root, "MyApp", "LocalSecrets.plist"); - - const apply = applyIOSRuntimeKey(plan, key, { - afterPlistCommit: async () => { - await appendFile(plistPath, "\n\n"); - await appendFile( - join(root, ".gitignore"), - "!/MyApp/.LocalSecrets.plist.clerk-*.tmp\n!/MyApp/LocalSecrets.plist\n", - ); - }, - }); - - await expect(apply).rejects.toThrow("Git-ignore protection was retained"); - const gitignore = await Bun.file(join(root, ".gitignore")).text(); - expect(gitignore.endsWith(TEMPORARY_IGNORE_RULE + TARGET_IGNORE_RULE)).toBe(true); - expect(gitignore.lastIndexOf("!/MyApp/LocalSecrets.plist")).toBeLessThan( - gitignore.lastIndexOf("/MyApp/LocalSecrets.plist"), - ); - expect(await Bun.file(plistPath).text()).toContain("concurrent user edit"); - expect(await Bun.file(plistPath).text()).toContain(key); - }); - - test("is idempotent after apply and removes every temporary file", async () => { - const root = await fixture("pk_test_..."); - const key = publishableKey("idempotent.clerk.example"); - - const first = await run(root, key); - expect(first.result.status).toBe("applied"); - const afterFirst = await treeDigest(root); - - const second = await run(root, key); - expect(second.result.status).toBe("satisfied"); - expect(await treeDigest(root)).toEqual(afterFirst); - for (const directory of [root, join(root, "MyApp")]) { - expect((await readdir(directory)).some((name) => name.includes(".clerk-"))).toBe(false); - } - }); - - test("blocks a symlinked .gitignore without touching either target", async () => { - const root = await fixture("pk_test_..."); - const external = join(root, "external-ignore"); - await Bun.write(external, "build/\n"); - await symlink(external, join(root, ".gitignore")); - const before = await treeDigest(root); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unsafe-gitignore"); + expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); expect(await treeDigest(root)).toEqual(before); }); - test("blocks a LocalSecrets symlink after a path swap", async () => { - const root = await fixture("pk_test_..."); - const path = join(root, "MyApp", "LocalSecrets.plist"); - const original = join(root, "MyApp", "OriginalLocalSecrets.plist"); - await rename(path, original); - await symlink("OriginalLocalSecrets.plist", path); - - const plan = await planIOSRuntimeKey(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unreadable-local-secrets"); + test("rejects invalid and production linked keys without serializing them", async () => { + const localKey = publishableKey("development.clerk.example"); + const productionKey = publishableKey("production.clerk.example", true); + const invalidKey = "pk_test_..."; + const root = await fixture(localKey); + const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); + + const invalid = await runtimeKey.verifyIOSRuntimeKey(plan, invalidKey); + const production = await runtimeKey.verifyIOSRuntimeKey(plan, productionKey); + + expect(invalid.status).toBe("blocked"); + expect(invalid.plan.blockers[0]?.code).toBe("invalid-publishable-key"); + expect(production.status).toBe("blocked"); + expect(production.plan.blockers[0]?.code).toBe("production-publishable-key"); + expect(JSON.stringify({ invalid, production })).not.toContain(localKey); + expect(JSON.stringify({ invalid, production })).not.toContain(productionKey); + expect(JSON.stringify({ invalid, production })).not.toContain(invalidKey); }); }); diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts index ec31d51bc..5945eaede 100644 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ b/packages/cli-core/src/commands/init/ios/runtime-key.ts @@ -1,92 +1,45 @@ -import { lstat, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises"; -import { randomUUID } from "node:crypto"; -import { isDeepStrictEqual } from "node:util"; -import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; -import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { lstat, readFile } from "node:fs/promises"; +import { basename, isAbsolute, resolve } from "node:path"; import { decodePublishableKey } from "../../../lib/fapi.ts"; -import { inspectIOSProject } from "./inspect.ts"; -import { - discoverLocalIOSProjects, - pathIsSafelyWithinIOSRoot, - relativeIOSPath, -} from "./discovery.ts"; -import type { IOSAppTarget } from "./types.ts"; -import { - asString, - asStringArray, - buildPbxParentIndex, - isRecord, - resolvePbxFilePath, - type PbxObject, - type PbxObjects, -} from "./pbx.ts"; -import { parseIOSPlist } from "./plist.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { - IOSFileTransactionOwnershipError as RuntimeKeyFileOwnershipError, - fileMatchesIdentityAndHash, + hashIOSFileBytes, identitiesMatch, - linkOwnedSourceWithoutClobber, - readPathIdentity, readRegularFileIdentity, - readRegularFileIdentityAndHash, - removeClaimedPath, - restoreClaimWithoutClobber, - sameFile, - type ClaimedDestination as ClaimedFile, type FileIdentity, } from "./file-transaction.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { parseIOSPlist } from "./plist.ts"; +import type { IOSAppTarget } from "./types.ts"; -const APP_PRODUCT_TYPE = "com.apple.product-type.application"; -const MAX_PBXPROJ_BYTES = 15_000_000; +const LOCAL_SECRETS_FILENAME = "LocalSecrets.plist"; const MAX_LOCAL_SECRETS_BYTES = 1_000_000; -const MAX_GITIGNORE_BYTES = 1_000_000; -const MAX_DISCOVERY_DEPTH = 24; -const MAX_DISCOVERED_SECRETS = 20; -const MAX_OWNERSHIP_SCAN_ENTRIES = 20_000; -const SECRET_KEY = "CLERK_PUBLISHABLE_KEY"; -const DISCOVERY_IGNORES = new Set([ - ".build", - ".git", - ".swiftpm", - "build", - "Carthage", - "DerivedData", - "node_modules", - "Pods", - "SourcePackages", -]); +const PUBLISHABLE_KEY = "CLERK_PUBLISHABLE_KEY"; -export interface IOSRuntimeKeyPlanOptions { +/** + * The one legacy compatibility shape that Clerk can prove without changing the + * user's source: the selected target's exact Quickstart-style LocalSecrets.plist + * runtime sink. + */ +export interface IOSRuntimeKeyVerificationOptions { root: string; /** Project-root-relative path selected by the iOS inspector. */ projectPath: string; targetId: string; - /** Optional project-root-relative disambiguation when the target owns more than one sink. */ + /** Optional exact path copied from a previous inspection result. */ localSecretsPath?: string; } export type IOSRuntimeKeyBlockerCode = | "invalid-selection" | "external-path" - | "unreadable-project" - | "malformed-project" | "target-not-found" - | "generated-project" | "missing-local-secrets" - | "ambiguous-local-secrets" - | "not-target-resource" - | "shared-local-secrets" | "unreadable-local-secrets" | "malformed-local-secrets" - | "unsupported-local-secrets" - | "unproven-runtime-wiring" - | "tracked-local-secrets" - | "git-state-unknown" - | "git-repository-mismatch" - | "unsafe-gitignore" | "invalid-publishable-key" | "production-publishable-key" - | "different-publishable-key"; + | "unproven-runtime-wiring"; export interface IOSRuntimeKeyBlocker { code: IOSRuntimeKeyBlockerCode; @@ -94,39 +47,9 @@ export interface IOSRuntimeKeyBlocker { } /** - * A structural, serializable plan. It intentionally contains neither the - * publishable key nor candidate plist bytes. The raw key is accepted only by - * applyIOSRuntimeKey. - */ -export interface IOSRuntimeKeyPlan { - schemaVersion: 1; - kind: "clerk-ios-runtime-key"; - status: "ready" | "blocked"; - root: string; - projectPath: string; - targetId: string; - localSecretsPath?: string; - gitignorePath?: string; - gitignoreRule?: string; - /** SHA-256 of the exact existing sink bytes inspected by this plan. */ - expectedLocalSecretsHash?: string; - /** Null means the .gitignore did not exist when the plan was created. */ - expectedGitignoreHash?: string | null; - /** True when apply may update .gitignore, including its crash-safe staging guard. */ - changesGitignore: boolean; - actions: string[]; - blockers: IOSRuntimeKeyBlocker[]; -} - -export interface IOSRuntimeKeyApplyResult { - status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; - plan: IOSRuntimeKeyPlan; - message?: string; -} - -/** - * A read-only, serializable proof of which runtime sink should be compared - * after Clerk application linking. It never contains the locally stored key. + * A read-only, serializable proof of which legacy runtime sink should be + * compared after Clerk application linking. It never contains the locally + * stored publishable key or plist bytes. */ export interface IOSRuntimeKeyVerificationPlan { schemaVersion: 1; @@ -145,172 +68,30 @@ export interface IOSRuntimeKeyVerificationResult { plan: IOSRuntimeKeyVerificationPlan; } -/** @internal Test-only fault injection used to prove rollback. */ -export interface IOSRuntimeKeyApplyOptions { - forcePostWriteValidationFailure?: boolean; - forcePlistStageFailureAfterCreate?: boolean; - forcePlistCleanupFailureBeforeCommit?: boolean; - forceGitignoreCommitCleanupFailure?: boolean; - beforePlistWrite?: (temporaryPath: string) => void | Promise; - afterPlistStage?: () => void | Promise; - afterPlistCommit?: () => void | Promise; - beforePostWriteValidation?: () => void | Promise; - beforeStagedCommitInstall?: (targetPath: string, claimPath: string) => void | Promise; - beforeStagedRollbackInstall?: ( - targetPath: string, - originalSourcePath: string, - candidateClaimPath: string, - ) => void | Promise; -} - -type GitContext = - | { state: "repository"; root: string } - | { state: "not-repository" } - | { state: "unknown" } - | { state: "mismatch" }; - -interface FileSnapshot { +interface LocalSecretsSnapshot { path: string; - exists: boolean; - hash?: string; - mode: number; - bytes?: Uint8Array; - identity?: FileIdentity; -} - -interface PreparedRuntimeKeyPlan { - plan: IOSRuntimeKeyPlan; - plist?: Record; - localSecretsSnapshot?: FileSnapshot; - gitignoreSnapshot?: FileSnapshot; - gitContext?: GitContext; - gitignoreNeeded?: boolean; + identity: FileIdentity; + hash: string; + publishableKey: string; + frontendApiHost: string; + instanceType: "development" | "production"; } interface PreparedRuntimeKeyVerification { plan: IOSRuntimeKeyVerificationPlan; - localSecretsSnapshot?: FileSnapshot; - /** Kept only inside the verification call and never copied into a public result. */ - existingPublishableKey?: string; -} - -interface StagedFile { - targetPath: string; - temporaryPath: string; - candidateHash: string; - original: FileSnapshot; - committed: boolean; - cleanupFailuresRemaining: number; - keyBearing: boolean; - temporaryPresent: boolean; - stagedIdentity: FileIdentity; - committedIdentity?: FileIdentity; - claimedOriginal?: ClaimedFile; - recoveryClaims: ClaimedFile[]; - claimPathIsSafe?: (path: string) => boolean | Promise; - rollbackClaimPathIsSafe?: (path: string) => boolean | Promise; -} - -interface RollbackDependency { - root: string; - /** The key-bearing file that must be made safe before its protection can be removed. */ - payloadPath: string; - /** The ignore file whose committed candidate protects the payload. */ - protectionPath: string; - /** Rules that protect both the final payload and its crash-safe staging file. */ - protectionRules: string[]; - options: IOSRuntimeKeyApplyOptions; -} - -class RuntimeKeyTemporaryFileCleanupError extends Error { - constructor( - message: string, - readonly keyBearing: boolean, - ) { - super(message); - } -} - -class RuntimeKeyClaimProtectionError extends RuntimeKeyFileOwnershipError { - constructor(readonly claimPath: string) { - super("a runtime-key recovery path was not protected by the committed ignore rule"); - } + snapshot?: LocalSecretsSnapshot; } -interface StageFileOptions { - forceFailureAfterCreate?: boolean; - cleanupFailures?: number; - keyBearing?: boolean; - beforeWrite?: (temporaryPath: string) => boolean | Promise; - claimPathIsSafe?: (path: string) => boolean | Promise; - rollbackClaimPathIsSafe?: (path: string) => boolean | Promise; +function normalizedRelativePath(path: string): string { + return path.replaceAll("\\", "/"); } -function sha256(value: string | Uint8Array): string { - return new Bun.CryptoHasher("sha256").update(value).digest("hex"); +function resolveRelativePath(root: string, path: string): string { + return resolve(root, ...normalizedRelativePath(path).split("/")); } function makePlan( - options: IOSRuntimeKeyPlanOptions, - root: string, - projectPath: string, - status: IOSRuntimeKeyPlan["status"], - details: Partial< - Pick< - IOSRuntimeKeyPlan, - | "localSecretsPath" - | "gitignorePath" - | "gitignoreRule" - | "expectedLocalSecretsHash" - | "expectedGitignoreHash" - | "changesGitignore" - | "actions" - | "blockers" - > - > = {}, -): IOSRuntimeKeyPlan { - return { - schemaVersion: 1, - kind: "clerk-ios-runtime-key", - status, - root, - projectPath, - targetId: options.targetId, - localSecretsPath: details.localSecretsPath, - gitignorePath: details.gitignorePath, - gitignoreRule: details.gitignoreRule, - expectedLocalSecretsHash: details.expectedLocalSecretsHash, - expectedGitignoreHash: details.expectedGitignoreHash, - changesGitignore: details.changesGitignore ?? false, - actions: details.actions ?? [], - blockers: details.blockers ?? [], - }; -} - -function blocked( - options: IOSRuntimeKeyPlanOptions, - root: string, - projectPath: string, - code: IOSRuntimeKeyBlockerCode, - message: string, - source: Partial = {}, -): PreparedRuntimeKeyPlan { - return { - ...source, - plan: makePlan(options, root, projectPath, "blocked", { - localSecretsPath: source.plan?.localSecretsPath, - gitignorePath: source.plan?.gitignorePath, - gitignoreRule: source.plan?.gitignoreRule, - expectedLocalSecretsHash: source.plan?.expectedLocalSecretsHash, - expectedGitignoreHash: source.plan?.expectedGitignoreHash, - changesGitignore: source.plan?.changesGitignore, - blockers: [{ code, message }], - }), - }; -} - -function makeVerificationPlan( - options: IOSRuntimeKeyPlanOptions, + options: IOSRuntimeKeyVerificationOptions, root: string, projectPath: string, status: IOSRuntimeKeyVerificationPlan["status"], @@ -334,8 +115,8 @@ function makeVerificationPlan( }; } -function verificationBlocked( - options: IOSRuntimeKeyPlanOptions, +function blocked( + options: IOSRuntimeKeyVerificationOptions, root: string, projectPath: string, code: IOSRuntimeKeyBlockerCode, @@ -343,7 +124,7 @@ function verificationBlocked( source: Partial = {}, ): PreparedRuntimeKeyVerification { return { - plan: makeVerificationPlan(options, root, projectPath, "blocked", { + plan: makePlan(options, root, projectPath, "blocked", { localSecretsPath: source.plan?.localSecretsPath, expectedLocalSecretsHash: source.plan?.expectedLocalSecretsHash, blockers: [{ code, message }], @@ -351,557 +132,141 @@ function verificationBlocked( }; } -function normalizedObjects(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; -} - -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 }; -} - -function normalizeSynchronizedPath(path: string): string { - return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); -} - -function containsControlCharacter(value: string): boolean { - return [...value].some((character) => { - const codePoint = character.codePointAt(0)!; - return codePoint <= 0x1f || codePoint === 0x7f; - }); -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function synchronizedExclusions( - group: PbxObject, - targetId: string, - resourcePhaseIds: 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" && - resourcePhaseIds.has(asString(exception.buildPhase) ?? ""); - if (!appliesToTarget && !appliesToPhase) continue; - - for (const path of asStringArray(exception.membershipExceptions)) { - excluded.add(normalizeSynchronizedPath(path)); - } - if (!isRecord(exception.platformFiltersByRelativePath)) continue; - 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 blockedResult( + plan: IOSRuntimeKeyVerificationPlan, + code: IOSRuntimeKeyBlockerCode, + message: string, +): IOSRuntimeKeyVerificationResult { + return { + status: "blocked", + plan: { + schemaVersion: 1, + kind: "clerk-ios-runtime-key-verification", + status: "blocked", + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + localSecretsPath: plan.localSecretsPath, + expectedLocalSecretsHash: plan.expectedLocalSecretsHash, + blockers: [{ code, message }], + }, + }; } -function synchronizedPathIsExcluded(path: string, excluded: Set): boolean { - return [...excluded].some( - (excludedPath) => path === excludedPath || path.startsWith(`${excludedPath}/`), - ); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -async function collectLocalSecrets( - root: string, - directory: string, - output: string[], - depth = 0, -): Promise { - if (depth > MAX_DISCOVERY_DEPTH || output.length >= MAX_DISCOVERED_SECRETS) return; - if (!(await pathIsSafelyWithinIOSRoot(root, directory))) return; - let entries; +function decodeUTF8(bytes: Uint8Array): string | undefined { try { - entries = await readdir(directory, { withFileTypes: true }); + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { - return; - } - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - if (output.length >= MAX_DISCOVERED_SECRETS) return; - const path = resolve(directory, entry.name); - if (entry.isDirectory()) { - if (!entry.name.startsWith(".") && !DISCOVERY_IGNORES.has(entry.name)) { - await collectLocalSecrets(root, path, output, depth + 1); - } - } else if (entry.isFile() && entry.name === "LocalSecrets.plist") { - output.push(path); - } - } -} - -async function generatedProjectKind( - root: string, - absoluteProjectPath: string, -): Promise<"xcodegen" | "tuist" | null> { - let directory = dirname(absoluteProjectPath); - while (await pathIsSafelyWithinIOSRoot(root, directory)) { - for (const [relativePath, kind] of [ - ["project.yml", "xcodegen"], - ["Project.swift", "tuist"], - ["Workspace.swift", "tuist"], - ["Tuist/ProjectDescriptionHelpers", "tuist"], - ] as const) { - const marker = resolve(directory, relativePath); - if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { - return kind; - } - } - if (directory === root) break; - const parent = dirname(directory); - if (parent === directory) break; - directory = parent; + return undefined; } - return null; } -async function targetLocalSecretsPaths( - root: string, - absoluteProjectPath: string, - targetId: string, -): Promise<{ paths?: string[]; blocker?: IOSRuntimeKeyBlocker }> { - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); - if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) { - return { - blocker: { - code: "external-path", - message: "The selected Xcode project resolves outside the project root.", - }, - }; - } - - let info; - let archive: unknown; +function parseLocalSecrets(bytes: Uint8Array): Record | undefined { + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) return undefined; + const source = decodeUTF8(bytes); + if (!source) return undefined; try { - info = await lstat(pbxprojPath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) { - throw new Error("unsupported project file"); - } - archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); + const parsed = parseIOSPlist(source); + return isRecord(parsed) ? parsed : undefined; } catch { - return { - blocker: { - code: "unreadable-project", - message: "The selected Xcode project is missing, too large, symlinked, or unreadable.", - }, - }; - } - if (!isRecord(archive)) { - return { - blocker: { - code: "malformed-project", - message: "The selected Xcode project has no readable object graph.", - }, - }; - } - const objects = normalizedObjects(archive.objects); - const projectObjectId = asString(archive.rootObject); - const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; - const targetObject = objects?.[targetId]; - if (!objects || projectObject?.isa !== "PBXProject") { - return { - blocker: { - code: "malformed-project", - message: "The selected Xcode project has no readable PBXProject root.", - }, - }; - } - if ( - targetObject?.isa !== "PBXNativeTarget" || - asString(targetObject.productType) !== APP_PRODUCT_TYPE - ) { - return { - blocker: { - code: "target-not-found", - message: "The selected object is not an iOS application target.", - }, - }; - } - - const parents = buildPbxParentIndex(objects); - const projectDirectory = dirname(absoluteProjectPath); - const groupRootDirectory = resolve( - projectDirectory, - asString(projectObject.projectDirPath) ?? "", - ); - const resourcePhaseIds = new Set( - asStringArray(targetObject.buildPhases).filter( - (phaseId) => objects[phaseId]?.isa === "PBXResourcesBuildPhase", - ), - ); - const paths = new Set(); - - 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 fileReferenceId = asString(buildFile.fileRef); - if (!fileReferenceId) continue; - const path = resolvePbxFilePath( - fileReferenceId, - objects, - parents, - projectDirectory, - groupRootDirectory, - ); - if ( - path?.endsWith(`${sep}LocalSecrets.plist`) && - (await pathIsSafelyWithinIOSRoot(root, path)) - ) { - paths.add(path); - } - } - } - - 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 collectLocalSecrets(root, groupPath, discovered); - const excluded = synchronizedExclusions(group, targetId, resourcePhaseIds, objects); - for (const path of discovered) { - const pathFromGroup = relative(groupPath, path).split(sep).join("/"); - if (!synchronizedPathIsExcluded(pathFromGroup, excluded)) paths.add(path); - } + return undefined; } - - return { paths: [...paths].sort() }; -} - -function isFileSystemError(error: unknown, code: string): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as { code?: unknown }).code === code - ); } -async function snapshotExistingFile( - path: string, - maximumBytes: number, -): Promise { - try { - const beforeRead = await readRegularFileIdentity(path); - const info = await lstat(path); - if (!beforeRead || !info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) { - return undefined; +function validatePublishableKey(value: unknown): + | { + value: string; + frontendApiHost: string; + instanceType: "development" | "production"; } - const bytes = new Uint8Array(await readFile(path)); - const afterRead = await readRegularFileIdentity(path); - if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; + | undefined { + if (typeof value !== "string" || value === "" || value.trim() !== value) return undefined; + try { + const decoded = decodePublishableKey(value); return { - path, - exists: true, - hash: sha256(bytes), - mode: afterRead.mode, - bytes, - identity: afterRead, + value, + frontendApiHost: decoded.fapiHost, + instanceType: decoded.instanceType, }; } catch { return undefined; } } -async function snapshotOptionalFile( - root: string, - path: string, - maximumBytes: number, - missingMode: number, -): Promise { - if (!(await pathIsSafelyWithinIOSRoot(root, path))) return undefined; +async function readLocalSecretsSnapshot(path: string): Promise { try { const beforeRead = await readRegularFileIdentity(path); const info = await lstat(path); - if (!beforeRead || !info.isFile() || info.isSymbolicLink() || info.size > maximumBytes) { + if ( + !beforeRead || + !info.isFile() || + info.isSymbolicLink() || + info.size > MAX_LOCAL_SECRETS_BYTES + ) { return undefined; } + const bytes = new Uint8Array(await readFile(path)); const afterRead = await readRegularFileIdentity(path); if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; + + const plist = parseLocalSecrets(bytes); + const decodedKey = validatePublishableKey(plist?.[PUBLISHABLE_KEY]); + if (!decodedKey || plist?.[PUBLISHABLE_KEY] !== decodedKey.value) return undefined; + return { path, - exists: true, - hash: sha256(bytes), - mode: afterRead.mode, - bytes, identity: afterRead, + hash: hashIOSFileBytes(bytes), + publishableKey: decodedKey.value, + frontendApiHost: decodedKey.frontendApiHost, + instanceType: decodedKey.instanceType, }; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return { path, exists: false, mode: missingMode }; - } - return undefined; - } -} - -function decodeUTF8(bytes: Uint8Array): string | undefined { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return undefined; } } -function parseXMLPlist(bytes: Uint8Array): Record | undefined { - if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) return undefined; - const source = decodeUTF8(bytes); - if (!source) return undefined; - try { - const parsed = parseIOSPlist(source); - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -async function hasGitMarkerInAncestors(start: string): Promise { - let directory = resolve(start); - while (true) { - try { - await lstat(resolve(directory, ".git")); - return true; - } catch { - // Walk to the filesystem root. - } - const parent = dirname(directory); - if (parent === directory) return false; - directory = parent; - } -} - -async function gitContext(root: string): Promise { +async function fileIsReadableXMLPlist(path: string): Promise { try { - const child = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { - cwd: root, - stdout: "pipe", - stderr: "ignore", - }); - const output = (await new Response(child.stdout).text()).trim(); - if ((await child.exited) !== 0 || output === "") { - return (await hasGitMarkerInAncestors(root)) - ? { state: "unknown" } - : { state: "not-repository" }; - } - const [canonicalRepositoryRoot, canonicalRoot] = await Promise.all([ - realpath(output), - realpath(root), - ]); - const rootFromRepository = relative(canonicalRepositoryRoot, canonicalRoot); + const beforeRead = await readRegularFileIdentity(path); + const info = await lstat(path); if ( - rootFromRepository === ".." || - rootFromRepository.startsWith(`..${sep}`) || - isAbsolute(rootFromRepository) + !beforeRead || + !info.isFile() || + info.isSymbolicLink() || + info.size > MAX_LOCAL_SECRETS_BYTES ) { - return { state: "unknown" }; + return false; } - return { state: "repository", root: canonicalRepositoryRoot }; + const bytes = new Uint8Array(await readFile(path)); + const afterRead = await readRegularFileIdentity(path); + return Boolean( + afterRead && identitiesMatch(beforeRead, afterRead) && parseLocalSecrets(bytes) !== undefined, + ); } catch { - return (await hasGitMarkerInAncestors(root)) - ? { state: "unknown" } - : { state: "not-repository" }; + return false; } } -async function coherentGitContext(root: string, locations: string[]): Promise { - const contexts = await Promise.all([gitContext(root), ...locations.map(gitContext)]); - if (contexts.some((context) => context.state === "unknown")) return { state: "unknown" }; - const repositories = contexts.filter( - (context): context is Extract => - context.state === "repository", +async function snapshotStillMatches(snapshot: LocalSecretsSnapshot): Promise { + const current = await readLocalSecretsSnapshot(snapshot.path); + return Boolean( + current && + identitiesMatch(snapshot.identity, current.identity) && + current.hash === snapshot.hash, ); - if (repositories.length === 0) return { state: "not-repository" }; - if ( - repositories.length !== contexts.length || - new Set(repositories.map((context) => context.root)).size !== 1 - ) { - return { state: "mismatch" }; - } - return repositories[0]!; -} - -async function hasDescendantGitignore( - rootInput: string, - localSecretsPath: string, -): Promise { - const root = resolve(rootInput); - let directory = dirname(resolve(localSecretsPath)); - const pathFromRoot = relative(root, directory); - if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) { - return true; - } - - while (directory !== root) { - try { - // A lower-level ignore file takes precedence over root rules. Treat every - // filesystem object here conservatively, including symlinks and directories. - await lstat(resolve(directory, ".gitignore")); - return true; - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) return true; - } - - const parent = dirname(directory); - if (parent === directory) return true; - directory = parent; - } - - return false; -} - -async function gitPathExitCode( - repositoryRoot: string, - args: string[], - absolutePath: string, -): Promise { - let canonicalPath: string; - try { - canonicalPath = await realpath(absolutePath); - } catch { - return undefined; - } - const path = relative(repositoryRoot, canonicalPath); - if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return undefined; - try { - const child = Bun.spawn(["git", ...args, "--", path], { - cwd: repositoryRoot, - stdout: "ignore", - stderr: "ignore", - }); - return await child.exited; - } catch { - return undefined; - } -} - -async function prospectiveGitPathExitCode( - repositoryRoot: string, - args: string[], - absolutePath: string, -): Promise { - let canonicalParent: string; - try { - canonicalParent = await realpath(dirname(absolutePath)); - } catch { - return undefined; - } - const candidate = resolve(canonicalParent, basename(absolutePath)); - const path = relative(repositoryRoot, candidate); - if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return undefined; - try { - const child = Bun.spawn(["git", ...args, "--", path], { - cwd: repositoryRoot, - stdout: "ignore", - stderr: "ignore", - }); - return await child.exited; - } catch { - return undefined; - } -} - -function escapeGitignorePath(path: string): string { - return path - .split("/") - .map((component) => { - let escaped = component.replaceAll("\\", "\\\\").replaceAll(" ", "\\ "); - for (const character of ["[", "]", "*", "?", "!", "#"]) { - escaped = escaped.replaceAll(character, `\\${character}`); - } - return escaped; - }) - .join("/"); -} - -function gitignoreRule(root: string, localSecretsPath: string): string { - return `/${escapeGitignorePath(relativeIOSPath(root, localSecretsPath))}`; -} - -function gitignoreTemporaryRule(root: string, localSecretsPath: string): string { - const components = relativeIOSPath(root, localSecretsPath).split("/"); - const fileName = components.pop()!; - const directory = components.length > 0 ? `${escapeGitignorePath(components.join("/"))}/` : ""; - return `/${directory}.${escapeGitignorePath(fileName)}.clerk-*.tmp`; -} - -function gitignoreContainsRule(content: string, rule: string): boolean { - return content.split(/\r?\n/).some((line) => line === rule); -} - -function gitignoreEndsWithRule(content: string, rule: string): boolean { - for (const line of content.split(/\r?\n/).reverse()) { - if (line.trim() === "" || line.startsWith("#")) continue; - return line === rule; - } - return false; -} - -function gitignoreRuleIsEffectiveWithoutRepository(content: string, rule: string): boolean { - const lines = content.split(/\r?\n/); - const ruleIndex = lines.lastIndexOf(rule); - if (ruleIndex < 0) return false; - - // Without Git there is no authoritative matcher available. A later negation - // could re-include this path (or its parent), so fail closed rather than - // inferring safety from the presence of a positive rule alone. - return !lines.slice(ruleIndex + 1).some((line) => line.startsWith("!")); } -function appendGitignoreRule(content: string, rule: string): string { - const lineEnding = content.includes("\r\n") ? "\r\n" : "\n"; - const separator = content.length > 0 && !content.endsWith("\n") ? lineEnding : ""; - return `${content}${separator}${rule}${lineEnding}`; -} - -function hasProvenRuntimeKeyWiring(target: IOSAppTarget | undefined): target is IOSAppTarget { +function hasProvenQuickstartWiring(target: IOSAppTarget | undefined): target is IOSAppTarget { if (!target || !target.swift.evidenceComplete) return false; const entryPoint = target.swift.entryPoints[0]; const configureCall = target.swift.configureCalls[0]; + const sink = target.runtimeKeySinks[0]; return ( target.swift.entryPoints.length === 1 && target.swift.configureCalls.length === 1 && @@ -910,519 +275,44 @@ function hasProvenRuntimeKeyWiring(target: IOSAppTarget | undefined): target is configureCall.startupBinding === "app-init" && configureCall.path === entryPoint?.path && target.swift.localSecretsRuntimeBindings.length === 1 && - target.runtimeKeySinks.length === 1 + target.runtimeKeySinks.length === 1 && + sink?.kind === "local-secrets-plist" && + basename(normalizedRelativePath(sink.path)) === LOCAL_SECRETS_FILENAME ); } -function exactStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return undefined; - return value; -} - -function optionalExactStringArray(value: unknown): string[] | undefined { - return value == null ? [] : exactStringArray(value); -} - -function isSameOrDescendant(parent: string, candidate: string): boolean { - const pathFromParent = relative(parent, candidate); - return ( - pathFromParent === "" || - (!pathFromParent.startsWith(`..${sep}`) && - pathFromParent !== ".." && - !isAbsolute(pathFromParent)) - ); -} +async function prepareRuntimeKeyVerification( + options: IOSRuntimeKeyVerificationOptions, +): Promise { + const root = resolve(options.root); + const suppliedProjectPath = normalizedRelativePath(options.projectPath); + const suppliedLocalSecretsPath = + options.localSecretsPath == null ? undefined : normalizedRelativePath(options.localSecretsPath); -function sameFileIdentity( - left: { dev: number | bigint; ino: number | bigint }, - right: { dev: number | bigint; ino: number | bigint }, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -function normalizedSynchronizedExceptionPath(path: string): string | undefined { - const normalized = normalizeSynchronizedPath(path); - if ( - normalized === "" || - normalized === ".." || - normalized.startsWith("../") || - normalized.startsWith("/") || - containsControlCharacter(normalized) - ) { - return undefined; - } - return normalized; -} - -function provenSynchronizedExclusions( - group: PbxObject, - targetId: string, - resourcePhaseIds: Set, - objects: PbxObjects, -): Set | undefined { - const exceptionIds = optionalExactStringArray(group.exceptions); - if (!exceptionIds) return undefined; - - const excluded = new Set(); - for (const exceptionId of exceptionIds) { - const exception = objects[exceptionId]; - if (!exception) return undefined; - - let applies = false; - if (exception.isa === "PBXFileSystemSynchronizedBuildFileExceptionSet") { - const exceptionTargetId = asString(exception.target); - if (!exceptionTargetId || objects[exceptionTargetId]?.isa !== "PBXNativeTarget") { - return undefined; - } - applies = exceptionTargetId === targetId; - } else if (exception.isa === "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet") { - const exceptionPhaseId = asString(exception.buildPhase); - const exceptionPhase = exceptionPhaseId ? objects[exceptionPhaseId] : undefined; - if ( - !exceptionPhaseId || - typeof exceptionPhase?.isa !== "string" || - !exceptionPhase.isa.endsWith("BuildPhase") - ) { - return undefined; - } - applies = resourcePhaseIds.has(exceptionPhaseId); - } else { - return undefined; - } - - if (!applies) continue; - const membershipExceptions = optionalExactStringArray(exception.membershipExceptions); - if (!membershipExceptions) return undefined; - for (const path of membershipExceptions) { - const normalized = normalizedSynchronizedExceptionPath(path); - if (!normalized) return undefined; - excluded.add(normalized); - } - - if (exception.platformFiltersByRelativePath == null) continue; - if (!isRecord(exception.platformFiltersByRelativePath)) return undefined; - for (const [path, rawFilters] of Object.entries(exception.platformFiltersByRelativePath)) { - const filters = exactStringArray(rawFilters); - const normalized = normalizedSynchronizedExceptionPath(path); - if (!filters || !normalized) return undefined; - const applicability = buildFileIOSApplicability({ platformFilters: filters }); - if (!applicability.recognized) return undefined; - if (!applicability.applies) excluded.add(normalized); - } - } - return excluded; -} - -interface RuntimeSinkIdentity { - dev: number | bigint; - ino: number | bigint; -} - -interface OwnershipScanState { - entries: number; - visitedDirectories: Set; -} - -async function synchronizedDirectoryOwnsCanonicalSink(options: { - canonicalDirectory: string; - canonicalSink: string; - excluded: Set; - logicalPrefix: string; - sinkIdentity: RuntimeSinkIdentity; - state: OwnershipScanState; - depth?: number; -}): Promise { - const { - canonicalDirectory, - canonicalSink, - excluded, - logicalPrefix, - sinkIdentity, - state, - depth = 0, - } = options; - if (depth > MAX_DISCOVERY_DEPTH) return undefined; - - if (isSameOrDescendant(canonicalDirectory, canonicalSink)) { - const pathFromDirectory = relative(canonicalDirectory, canonicalSink).split(sep).join("/"); - const logicalSinkPath = normalizeSynchronizedPath( - logicalPrefix ? `${logicalPrefix}/${pathFromDirectory}` : pathFromDirectory, - ); - if (!synchronizedPathIsExcluded(logicalSinkPath, excluded)) return true; - } - - const visitKey = `${canonicalDirectory}\0${logicalPrefix}`; - if (state.visitedDirectories.has(visitKey)) return false; - state.visitedDirectories.add(visitKey); - - let entries; - try { - entries = await readdir(canonicalDirectory, { withFileTypes: true }); - } catch { - return undefined; - } - state.entries += entries.length; - if (state.entries > MAX_OWNERSHIP_SCAN_ENTRIES) return undefined; - - entries.sort((left, right) => left.name.localeCompare(right.name)); - for (const entry of entries) { - const logicalPath = normalizeSynchronizedPath( - logicalPrefix ? `${logicalPrefix}/${entry.name}` : entry.name, - ); - if (synchronizedPathIsExcluded(logicalPath, excluded)) continue; - - const entryPath = resolve(canonicalDirectory, entry.name); - let entryInfo; - try { - entryInfo = await lstat(entryPath); - } catch { - return undefined; - } - - if (entryInfo.isFile()) { - if (sameFileIdentity(entryInfo, sinkIdentity)) return true; - continue; - } - - if (!entryInfo.isDirectory() && !entryInfo.isSymbolicLink()) continue; - let canonicalEntry: string; - let canonicalEntryInfo; - try { - canonicalEntry = await realpath(entryPath); - canonicalEntryInfo = await lstat(canonicalEntry); - } catch { - // A dangling or unreadable alias could conceal a second path to the sink. - return undefined; - } - - if (canonicalEntryInfo.isFile()) { - if (sameFileIdentity(canonicalEntryInfo, sinkIdentity)) return true; - continue; - } - if (!canonicalEntryInfo.isDirectory()) continue; - - const nestedOwnership = await synchronizedDirectoryOwnsCanonicalSink({ - canonicalDirectory: canonicalEntry, - canonicalSink, - excluded, - logicalPrefix: logicalPath, - sinkIdentity, - state, - depth: depth + 1, - }); - if (nestedOwnership == null || nestedOwnership) return nestedOwnership; - } - return false; -} - -async function synchronizedGroupOwnsCanonicalSink(options: { - groupPath: string; - canonicalSink: string; - excluded: Set; - sinkIdentity: RuntimeSinkIdentity; -}): Promise { - let canonicalGroup: string; - let groupInfo; - try { - canonicalGroup = await realpath(options.groupPath); - groupInfo = await lstat(canonicalGroup); - } catch { - return undefined; - } - if (!groupInfo.isDirectory()) return undefined; - - return synchronizedDirectoryOwnsCanonicalSink({ - canonicalDirectory: canonicalGroup, - canonicalSink: options.canonicalSink, - excluded: options.excluded, - logicalPrefix: "", - sinkIdentity: options.sinkIdentity, - state: { entries: 0, visitedDirectories: new Set() }, - }); -} - -async function classicReferenceOwnsCanonicalSink(options: { - referenceId: string; - canonicalSink: string; - sinkIdentity: RuntimeSinkIdentity; - objects: PbxObjects; - parents: Map; - projectDirectory: string; - groupRootDirectory: string; - seen?: Set; -}): Promise { - const { - referenceId, - canonicalSink, - sinkIdentity, - objects, - parents, - projectDirectory, - groupRootDirectory, - seen = new Set(), - } = options; - if (seen.has(referenceId)) return undefined; - seen.add(referenceId); - - const reference = objects[referenceId]; - if (!reference) return undefined; - if (["PBXVariantGroup", "XCVersionGroup", "PBXGroup"].includes(reference.isa ?? "")) { - const children = exactStringArray(reference.children); - if (!children) return undefined; - let ownsSink = false; - for (const child of children) { - const childOwnership = await classicReferenceOwnsCanonicalSink({ - referenceId: child, - canonicalSink, - sinkIdentity, - objects, - parents, - projectDirectory, - groupRootDirectory, - seen: new Set(seen), - }); - if (childOwnership == null) return undefined; - ownsSink ||= childOwnership; - } - return ownsSink; - } - if (reference.isa !== "PBXFileReference") return undefined; - - const path = resolvePbxFilePath( - referenceId, - objects, - parents, - projectDirectory, - groupRootDirectory, - ); - if (!path) return undefined; - - let info; - try { - info = await lstat(path); - } catch (error) { - if ( - error instanceof Error && - "code" in error && - error.code === "ENOENT" && - basename(path) !== "LocalSecrets.plist" - ) { - return false; - } - return undefined; - } - - if (info.isFile() && !info.isSymbolicLink()) { - return sameFileIdentity(info, sinkIdentity); - } - - let canonicalReference: string; - let canonicalInfo; - try { - canonicalReference = await realpath(path); - canonicalInfo = await lstat(canonicalReference); - } catch { - return undefined; - } - if (canonicalInfo.isFile()) return sameFileIdentity(canonicalInfo, sinkIdentity); - if (!canonicalInfo.isDirectory()) return false; - - return synchronizedDirectoryOwnsCanonicalSink({ - canonicalDirectory: canonicalReference, - canonicalSink, - excluded: new Set(), - logicalPrefix: "", - sinkIdentity, - state: { entries: 0, visitedDirectories: new Set() }, - }); -} - -async function targetOwnsCanonicalRuntimeSink(options: { - canonicalSink: string; - groupRootDirectory: string; - objects: PbxObjects; - parents: Map; - projectDirectory: string; - sinkIdentity: RuntimeSinkIdentity; - target: PbxObject; - targetId: string; -}): Promise { - const { - canonicalSink, - groupRootDirectory, - objects, - parents, - projectDirectory, - sinkIdentity, - target, - targetId, - } = options; - const buildPhaseIds = exactStringArray(target.buildPhases); - if (!buildPhaseIds) return undefined; - - const resourcePhaseIds = new Set(); - for (const phaseId of buildPhaseIds) { - const phase = objects[phaseId]; - if (typeof phase?.isa !== "string" || !phase.isa.endsWith("BuildPhase")) return undefined; - if (phase.isa === "PBXResourcesBuildPhase") resourcePhaseIds.add(phaseId); - } - - let ownsSink = false; - for (const phaseId of resourcePhaseIds) { - const phase = objects[phaseId]!; - const buildFileIds = exactStringArray(phase.files); - if (!buildFileIds) return undefined; - for (const buildFileId of buildFileIds) { - const buildFile = objects[buildFileId]; - if (buildFile?.isa !== "PBXBuildFile") return undefined; - const applicability = buildFileIOSApplicability(buildFile); - if (!applicability.recognized) return undefined; - if (!applicability.applies) continue; - const referenceId = asString(buildFile.fileRef); - if (!referenceId) return undefined; - const referenceOwnership = await classicReferenceOwnsCanonicalSink({ - referenceId, - canonicalSink, - sinkIdentity, - objects, - parents, - projectDirectory, - groupRootDirectory, - }); - if (referenceOwnership == null) return undefined; - ownsSink ||= referenceOwnership; - } - } - - const synchronizedGroupIds = optionalExactStringArray(target.fileSystemSynchronizedGroups); - if (!synchronizedGroupIds) return undefined; - for (const groupId of synchronizedGroupIds) { - const group = objects[groupId]; - if (group?.isa !== "PBXFileSystemSynchronizedRootGroup") return undefined; - const groupPath = resolvePbxFilePath( - groupId, - objects, - parents, - projectDirectory, - groupRootDirectory, - ); - if (!groupPath) return undefined; - const excluded = provenSynchronizedExclusions(group, targetId, resourcePhaseIds, objects); - if (!excluded) return undefined; - const synchronizedOwnership = await synchronizedGroupOwnsCanonicalSink({ - groupPath, - canonicalSink, - excluded, - sinkIdentity, - }); - if (synchronizedOwnership == null) return undefined; - ownsSink ||= synchronizedOwnership; - } - - return ownsSink; -} - -async function hasExclusiveRuntimeSinkOwnership( - root: string, - projectPath: string, - targetId: string, - localSecretsPath: string, -): Promise { - let canonicalSink: string; - let sinkIdentity: RuntimeSinkIdentity; - try { - canonicalSink = await realpath(localSecretsPath); - const sinkInfo = await lstat(canonicalSink); - if (!sinkInfo.isFile()) return false; - sinkIdentity = { dev: sinkInfo.dev, ino: sinkInfo.ino }; - } catch { - return false; - } - - const selectedProjectPath = resolve(root, projectPath); - const inventory = await discoverLocalIOSProjects(root, [selectedProjectPath]); - if (!inventory.complete) return false; - const owners = new Set(); - const selectedOwner = `${selectedProjectPath}\0${targetId}`; - let selectedTargetFound = false; - for (const absoluteProjectPath of inventory.projectPaths) { - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); - if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; - - let archive: unknown; - try { - const info = await lstat(pbxprojPath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) return false; - archive = parsePbxProject(await readFile(pbxprojPath, "utf8")); - } catch { - return false; - } - - if (!isRecord(archive)) return false; - const objects = normalizedObjects(archive.objects); - const projectObjectId = asString(archive.rootObject); - const projectObject = projectObjectId ? objects?.[projectObjectId] : undefined; - if (!objects || projectObject?.isa !== "PBXProject") return false; - - const projectTargetIds = exactStringArray(projectObject.targets); - if (!projectTargetIds) return false; - const parents = buildPbxParentIndex(objects); - const projectDirectory = dirname(absoluteProjectPath); - const groupRootDirectory = resolve( - projectDirectory, - asString(projectObject.projectDirPath) ?? "", - ); - - for (const candidateTargetId of projectTargetIds) { - const target = objects[candidateTargetId]; - if (!target) return false; - if (target.isa !== "PBXNativeTarget") continue; - if (absoluteProjectPath === selectedProjectPath && candidateTargetId === targetId) { - selectedTargetFound = true; - } - - const ownership = await targetOwnsCanonicalRuntimeSink({ - canonicalSink, - groupRootDirectory, - objects, - parents, - projectDirectory, - sinkIdentity, - target, - targetId: candidateTargetId, - }); - if (ownership == null) return false; - if (ownership) owners.add(`${absoluteProjectPath}\0${candidateTargetId}`); - } - } - return selectedTargetFound && owners.size === 1 && owners.has(selectedOwner); -} - -async function prepareRuntimeKeyVerification( - options: IOSRuntimeKeyPlanOptions, -): Promise { - const root = resolve(options.root); - const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); if ( !options.targetId || !suppliedProjectPath || isAbsolute(options.projectPath) || + isAbsolute(suppliedProjectPath) || !suppliedProjectPath.endsWith(".xcodeproj") || - (options.localSecretsPath != null && isAbsolute(options.localSecretsPath)) + (suppliedLocalSecretsPath != null && + (isAbsolute(options.localSecretsPath!) || + isAbsolute(suppliedLocalSecretsPath) || + basename(suppliedLocalSecretsPath) !== LOCAL_SECRETS_FILENAME)) ) { - return verificationBlocked( + return blocked( options, root, suppliedProjectPath, "invalid-selection", - "A root-relative Xcode project, application target, and optional root-relative LocalSecrets path are required.", + "A root-relative Xcode project, application target, and optional exact LocalSecrets.plist path are required.", ); } - const absoluteProjectPath = resolve(root, suppliedProjectPath); + const absoluteProjectPath = resolveRelativePath(root, suppliedProjectPath); const projectPath = relativeIOSPath(root, absoluteProjectPath); if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { - return verificationBlocked( + return blocked( options, root, projectPath, @@ -1431,13 +321,16 @@ async function prepareRuntimeKeyVerification( ); } - const inspection = await inspectIOSProject(root, { target: options.targetId }); + const inspection = await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + }); if ( inspection.selection.state !== "selected" || inspection.selection.targetId !== options.targetId || inspection.selection.projectPath !== projectPath ) { - return verificationBlocked( + return blocked( options, root, projectPath, @@ -1445,161 +338,109 @@ async function prepareRuntimeKeyVerification( "The selected application target could not be verified in the selected Xcode project.", ); } + const selectedTarget = inspection.appTargets.find( (target) => target.id === options.targetId && target.projectPath === projectPath, ); - if (!hasProvenRuntimeKeyWiring(selectedTarget)) { - return verificationBlocked( + if (!hasProvenQuickstartWiring(selectedTarget)) { + return blocked( options, root, projectPath, "unproven-runtime-wiring", - "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", - ); - } - const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); - if (membership.blocker) { - return verificationBlocked( - options, - root, - projectPath, - membership.blocker.code, - membership.blocker.message, - ); - } - const memberPaths = membership.paths ?? []; - let localSecretsPath: string | undefined; - if (options.localSecretsPath != null) { - const requestedPath = resolve(root, options.localSecretsPath); - if (!(await pathIsSafelyWithinIOSRoot(root, requestedPath))) { - return verificationBlocked( - options, - root, - projectPath, - "external-path", - "The requested LocalSecrets.plist resolves outside the project root.", - ); - } - localSecretsPath = memberPaths.find((path) => resolve(path) === requestedPath); - if (!localSecretsPath) { - return verificationBlocked( - options, - root, - projectPath, - "not-target-resource", - "The requested LocalSecrets.plist is not a proven resource of the selected target.", - ); - } - } else if (memberPaths.length === 0) { - return verificationBlocked( - options, - root, - projectPath, - "missing-local-secrets", - "The selected target does not already own a LocalSecrets.plist resource.", - ); - } else if (memberPaths.length > 1) { - return verificationBlocked( - options, - root, - projectPath, - "ambiguous-local-secrets", - "The selected target owns more than one LocalSecrets.plist resource; select one explicitly.", + "Read-only compatibility requires the exact Quickstart LocalSecrets.plist loader and one proven startup configure call.", ); - } else { - localSecretsPath = memberPaths[0]; } + const localSecretsRelativePath = normalizedRelativePath(selectedTarget.runtimeKeySinks[0]!.path); + const absoluteLocalSecretsPath = resolveRelativePath(root, localSecretsRelativePath); + const redactedSource = { + plan: makePlan(options, root, projectPath, "ready", { + localSecretsPath: localSecretsRelativePath, + }), + }; + if ( - !localSecretsPath || - basename(localSecretsPath) !== "LocalSecrets.plist" || - resolve(root, selectedTarget.runtimeKeySinks[0]!.path) !== resolve(localSecretsPath) - ) { - return verificationBlocked( - options, - root, - projectPath, - "not-target-resource", - "A unique target-owned LocalSecrets.plist resource could not be resolved.", - ); - } - if ( - !(await hasExclusiveRuntimeSinkOwnership(root, projectPath, options.targetId, localSecretsPath)) + basename(localSecretsRelativePath) !== LOCAL_SECRETS_FILENAME || + (suppliedLocalSecretsPath != null && + resolveRelativePath(root, suppliedLocalSecretsPath) !== absoluteLocalSecretsPath) ) { - return verificationBlocked( + return blocked( options, root, projectPath, - "shared-local-secrets", - "LocalSecrets.plist must be owned exclusively by the selected iOS application target before its runtime key can be verified.", + "missing-local-secrets", + "The selected target does not use the exact supported LocalSecrets.plist runtime sink.", + redactedSource, ); } - - const localSecretsRelativePath = relativeIOSPath(root, localSecretsPath); - const redactedSource = { - plan: makeVerificationPlan(options, root, projectPath, "ready", { - localSecretsPath: localSecretsRelativePath, - }), - }; - const localSecretsSnapshot = await snapshotExistingFile( - localSecretsPath, - MAX_LOCAL_SECRETS_BYTES, - ); - if (!localSecretsSnapshot) { - return verificationBlocked( + if (!(await pathIsSafelyWithinIOSRoot(root, absoluteLocalSecretsPath))) { + return blocked( options, root, projectPath, - "unreadable-local-secrets", - "LocalSecrets.plist is missing, too large, symlinked, or unreadable.", + "external-path", + "LocalSecrets.plist resolves outside the project root.", redactedSource, ); } - const plist = parseXMLPlist(localSecretsSnapshot.bytes!); - if (!plist) { - return verificationBlocked( + + const snapshot = await readLocalSecretsSnapshot(absoluteLocalSecretsPath); + if (!snapshot) { + const code = (await fileIsReadableXMLPlist(absoluteLocalSecretsPath)) + ? "invalid-publishable-key" + : "malformed-local-secrets"; + return blocked( options, root, projectPath, - "malformed-local-secrets", - "LocalSecrets.plist must be a readable XML property-list dictionary.", + code, + code === "invalid-publishable-key" + ? "The proven LocalSecrets.plist sink does not contain one canonical publishable key that can be verified." + : "LocalSecrets.plist must be an existing, regular, readable XML property-list dictionary.", redactedSource, ); } - const existingPublishableKey = existingValidPublishableKey(plist); + + const inspectedKey = inspection.localPublishableKey; if ( - !existingPublishableKey || - plist[SECRET_KEY] !== existingPublishableKey || - !inspection.localPublishableKey.found || - inspection.localPublishableKey.conflict || - inspection.localPublishableKey.source !== localSecretsRelativePath + !inspectedKey.evidenceComplete || + !inspectedKey.found || + inspectedKey.conflict || + inspectedKey.source !== localSecretsRelativePath || + inspectedKey.frontendApiHost !== snapshot.frontendApiHost || + inspectedKey.instanceType !== snapshot.instanceType ) { - return verificationBlocked( + return blocked( options, root, projectPath, "invalid-publishable-key", - "The proven LocalSecrets.plist runtime sink does not contain one canonical publishable key that can be verified.", + "The proven LocalSecrets.plist sink is not the one unambiguous runtime publishable-key source for the selected target.", redactedSource, ); } return { - plan: makeVerificationPlan(options, root, projectPath, "ready", { + plan: makePlan(options, root, projectPath, "ready", { localSecretsPath: localSecretsRelativePath, - expectedLocalSecretsHash: localSecretsSnapshot.hash, + expectedLocalSecretsHash: snapshot.hash, }), - localSecretsSnapshot, - existingPublishableKey, + snapshot, }; } +/** + * Recognizes the existing Quickstart LocalSecrets pattern for post-link + * comparison. This function never proposes a plist or .gitignore write. + */ export async function planIOSRuntimeKeyVerification( - options: IOSRuntimeKeyPlanOptions, + options: IOSRuntimeKeyVerificationOptions, ): Promise { return (await prepareRuntimeKeyVerification(options)).plan; } +/** Compares an already linked development key without retaining either key. */ export async function verifyIOSRuntimeKey( plan: IOSRuntimeKeyVerificationPlan, linkedPublishableKey: string, @@ -1609,1591 +450,60 @@ export async function verifyIOSRuntimeKey( plan.schemaVersion !== 1 || plan.kind !== "clerk-ios-runtime-key-verification" || !plan.localSecretsPath || + basename(normalizedRelativePath(plan.localSecretsPath)) !== LOCAL_SECRETS_FILENAME || !plan.expectedLocalSecretsHash ) { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "invalid-selection", - message: "The runtime-key verification plan is incomplete or unsupported.", - }, - ], - }, - }; + return blockedResult( + plan, + "invalid-selection", + "The runtime-key verification plan is incomplete or unsupported.", + ); } const linkedKey = validatePublishableKey(linkedPublishableKey); if (!linkedKey || linkedKey.value !== linkedPublishableKey) { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { code: "invalid-publishable-key", message: "A valid publishable key is required." }, - ], - }, - }; + return blockedResult(plan, "invalid-publishable-key", "A valid publishable key is required."); } if (linkedKey.instanceType !== "development") { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "production-publishable-key", - message: "Runtime-key verification accepts a development-instance key only.", - }, - ], - }, - }; + return blockedResult( + plan, + "production-publishable-key", + "Runtime-key verification accepts a development-instance key only.", + ); + } + + const root = resolve(plan.root); + const absoluteLocalSecretsPath = resolveRelativePath(root, plan.localSecretsPath); + if (!(await pathIsSafelyWithinIOSRoot(root, absoluteLocalSecretsPath))) { + return blockedResult( + plan, + "external-path", + "LocalSecrets.plist resolves outside the project root.", + ); + } + const currentSnapshot = await readLocalSecretsSnapshot(absoluteLocalSecretsPath); + if (!currentSnapshot || currentSnapshot.hash !== plan.expectedLocalSecretsHash) { + return { status: "stale", plan }; } const prepared = await prepareRuntimeKeyVerification({ - root: plan.root, + root, projectPath: plan.projectPath, targetId: plan.targetId, localSecretsPath: plan.localSecretsPath, }); - if (prepared.plan.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if (prepared.plan.status === "blocked" || !prepared.snapshot) { + return { status: "blocked", plan: prepared.plan }; + } if ( prepared.plan.expectedLocalSecretsHash !== plan.expectedLocalSecretsHash || - !prepared.localSecretsSnapshot || - !(await snapshotMatches(prepared.localSecretsSnapshot)) + !(await snapshotStillMatches(prepared.snapshot)) ) { return { status: "stale", plan }; } return { - status: prepared.existingPublishableKey === linkedKey.value ? "matched" : "mismatched", + status: prepared.snapshot.publishableKey === linkedKey.value ? "matched" : "mismatched", plan, }; } - -async function prepareRuntimeKeyPlan( - options: IOSRuntimeKeyPlanOptions, -): Promise { - const root = resolve(options.root); - const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); - if ( - !options.targetId || - !suppliedProjectPath || - isAbsolute(options.projectPath) || - !suppliedProjectPath.endsWith(".xcodeproj") || - (options.localSecretsPath != null && isAbsolute(options.localSecretsPath)) - ) { - return blocked( - options, - root, - suppliedProjectPath, - "invalid-selection", - "A root-relative Xcode project, application target, and optional root-relative LocalSecrets path are required.", - ); - } - - const absoluteProjectPath = resolve(root, suppliedProjectPath); - const projectPath = relativeIOSPath(root, absoluteProjectPath); - if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { - return blocked( - options, - root, - projectPath, - "external-path", - "The selected Xcode project resolves outside the project root.", - ); - } - - const inspection = await inspectIOSProject(root, { target: options.targetId }); - if ( - inspection.selection.state !== "selected" || - inspection.selection.targetId !== options.targetId || - inspection.selection.projectPath !== projectPath - ) { - return blocked( - options, - root, - projectPath, - "target-not-found", - "The selected application target could not be verified in the selected Xcode project.", - ); - } - const selectedTarget = inspection.appTargets.find( - (target) => target.id === options.targetId && target.projectPath === projectPath, - ); - const generator = - inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); - if (generator) { - return blocked( - options, - root, - projectPath, - "generated-project", - `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated target resources.`, - ); - } - if (!hasProvenRuntimeKeyWiring(selectedTarget)) { - return blocked( - options, - root, - projectPath, - "unproven-runtime-wiring", - "The selected target must have exactly one proven app entry point, LocalSecrets configure call, LocalSecrets runtime loader, and target-owned LocalSecrets.plist sink.", - ); - } - const membership = await targetLocalSecretsPaths(root, absoluteProjectPath, options.targetId); - if (membership.blocker) { - return blocked(options, root, projectPath, membership.blocker.code, membership.blocker.message); - } - const memberPaths = membership.paths ?? []; - let localSecretsPath: string | undefined; - if (options.localSecretsPath != null) { - const requestedPath = resolve(root, options.localSecretsPath); - if (!(await pathIsSafelyWithinIOSRoot(root, requestedPath))) { - return blocked( - options, - root, - projectPath, - "external-path", - "The requested LocalSecrets.plist resolves outside the project root.", - ); - } - localSecretsPath = memberPaths.find((path) => resolve(path) === requestedPath); - if (!localSecretsPath) { - return blocked( - options, - root, - projectPath, - "not-target-resource", - "The requested LocalSecrets.plist is not a proven resource of the selected target.", - ); - } - } else if (memberPaths.length === 0) { - return blocked( - options, - root, - projectPath, - "missing-local-secrets", - "The selected target does not already own a LocalSecrets.plist resource.", - ); - } else if (memberPaths.length > 1) { - return blocked( - options, - root, - projectPath, - "ambiguous-local-secrets", - "The selected target owns more than one LocalSecrets.plist resource; select one explicitly.", - ); - } else { - localSecretsPath = memberPaths[0]; - } - - if ( - !localSecretsPath || - basename(localSecretsPath) !== "LocalSecrets.plist" || - resolve(root, selectedTarget.runtimeKeySinks[0]!.path) !== resolve(localSecretsPath) - ) { - return blocked( - options, - root, - projectPath, - "not-target-resource", - "A unique target-owned LocalSecrets.plist resource could not be resolved.", - ); - } - if ( - !(await hasExclusiveRuntimeSinkOwnership(root, projectPath, options.targetId, localSecretsPath)) - ) { - return blocked( - options, - root, - projectPath, - "shared-local-secrets", - "LocalSecrets.plist must be owned exclusively by the selected iOS application target before it can be updated automatically.", - ); - } - const localSecretsRelativePath = relativeIOSPath(root, localSecretsPath); - if (containsControlCharacter(localSecretsRelativePath)) { - return blocked( - options, - root, - projectPath, - "unsafe-gitignore", - "The LocalSecrets.plist path contains control characters that cannot be represented safely in .gitignore.", - ); - } - const localSecretsSnapshot = await snapshotExistingFile( - localSecretsPath, - MAX_LOCAL_SECRETS_BYTES, - ); - const redactedSource = { - plan: makePlan(options, root, projectPath, "ready", { - localSecretsPath: relativeIOSPath(root, localSecretsPath), - }), - }; - if (!localSecretsSnapshot) { - return blocked( - options, - root, - projectPath, - "unreadable-local-secrets", - "LocalSecrets.plist is missing, too large, symlinked, or unreadable.", - redactedSource, - ); - } - const plist = parseXMLPlist(localSecretsSnapshot.bytes!); - if (!plist) { - return blocked( - options, - root, - projectPath, - "malformed-local-secrets", - "LocalSecrets.plist must be a readable XML property-list dictionary.", - redactedSource, - ); - } - if (plist[SECRET_KEY] != null && typeof plist[SECRET_KEY] !== "string") { - return blocked( - options, - root, - projectPath, - "unsupported-local-secrets", - "The CLERK_PUBLISHABLE_KEY entry in LocalSecrets.plist must be a string.", - redactedSource, - ); - } - const existingNormalizedKey = existingValidPublishableKey(plist); - const plistMayNeedWrite = - existingNormalizedKey == null || plist[SECRET_KEY] !== existingNormalizedKey; - - const resolvedGitContext = await coherentGitContext(root, [ - absoluteProjectPath, - dirname(localSecretsPath), - ]); - if (resolvedGitContext.state === "unknown") { - return blocked( - options, - root, - projectPath, - "git-state-unknown", - "Git could not verify whether LocalSecrets.plist is tracked or ignored.", - redactedSource, - ); - } - if (resolvedGitContext.state === "mismatch") { - return blocked( - options, - root, - projectPath, - "git-repository-mismatch", - "The selected Xcode project and LocalSecrets.plist must share the invocation root's Git repository boundary.", - redactedSource, - ); - } - if (await hasDescendantGitignore(root, localSecretsPath)) { - return blocked( - options, - root, - projectPath, - "unsafe-gitignore", - "A nested .gitignore can override the invocation root's LocalSecrets.plist protection. Consolidate the sink's ignore rules at the invocation root before retrying.", - redactedSource, - ); - } - if (resolvedGitContext.state === "repository") { - const tracked = await gitPathExitCode( - resolvedGitContext.root, - ["ls-files", "--error-unmatch"], - localSecretsPath, - ); - if (tracked == null) { - return blocked( - options, - root, - projectPath, - "git-state-unknown", - "Git could not verify whether LocalSecrets.plist is tracked.", - redactedSource, - ); - } - if (tracked > 1) { - return blocked( - options, - root, - projectPath, - "git-state-unknown", - "Git could not verify whether LocalSecrets.plist is tracked.", - redactedSource, - ); - } - if (tracked === 0) { - return blocked( - options, - root, - projectPath, - "tracked-local-secrets", - "LocalSecrets.plist is tracked by Git. Remove it from the index before writing a publishable key.", - redactedSource, - ); - } - } - - const gitignorePath = resolve(root, ".gitignore"); - const gitignoreSnapshot = await snapshotOptionalFile( - root, - gitignorePath, - MAX_GITIGNORE_BYTES, - 0o644, - ); - if (!gitignoreSnapshot) { - return blocked( - options, - root, - projectPath, - "unsafe-gitignore", - ".gitignore is too large, symlinked, unreadable, or resolves outside the project root.", - redactedSource, - ); - } - const rule = gitignoreRule(root, localSecretsPath); - const gitignoreText = gitignoreSnapshot.exists ? decodeUTF8(gitignoreSnapshot.bytes!) : ""; - if (gitignoreText == null) { - return blocked( - options, - root, - projectPath, - "unsafe-gitignore", - ".gitignore must be valid UTF-8.", - redactedSource, - ); - } - - const hasExactRule = gitignoreContainsRule(gitignoreText, rule); - let effectivelyIgnored = hasExactRule && gitignoreEndsWithRule(gitignoreText, rule); - if (resolvedGitContext.state === "repository") { - const ignored = await gitPathExitCode( - resolvedGitContext.root, - ["check-ignore", "--quiet", "--no-index"], - localSecretsPath, - ); - if (ignored == null || ignored > 1) { - return blocked( - options, - root, - projectPath, - "git-state-unknown", - "Git could not verify whether LocalSecrets.plist is effectively ignored.", - redactedSource, - ); - } - effectivelyIgnored = ignored === 0; - } - const gitignoreNeeded = !hasExactRule || !effectivelyIgnored; - const changesGitignore = gitignoreNeeded || plistMayNeedWrite; - - const gitignoreRelativePath = relativeIOSPath(root, gitignorePath); - return { - plan: makePlan(options, root, projectPath, "ready", { - localSecretsPath: localSecretsRelativePath, - gitignorePath: gitignoreRelativePath, - gitignoreRule: rule, - expectedLocalSecretsHash: localSecretsSnapshot.hash, - expectedGitignoreHash: gitignoreSnapshot.exists ? gitignoreSnapshot.hash! : null, - changesGitignore, - actions: [ - ...(changesGitignore - ? [ - `Ensure ${localSecretsRelativePath} and its atomic-write staging file are effectively ignored by Git.`, - ] - : []), - `Set CLERK_PUBLISHABLE_KEY in ${localSecretsRelativePath} without exposing its value.`, - ], - }), - plist, - localSecretsSnapshot, - gitignoreSnapshot, - gitContext: resolvedGitContext, - gitignoreNeeded, - }; -} - -export async function planIOSRuntimeKey( - options: IOSRuntimeKeyPlanOptions, -): Promise { - return (await prepareRuntimeKeyPlan(options)).plan; -} - -function validatePublishableKey( - value: string, -): { value: string; instanceType: "development" | "production" } | undefined { - const normalized = value.trim(); - if (!normalized) return undefined; - try { - return { value: normalized, instanceType: decodePublishableKey(normalized).instanceType }; - } catch { - return undefined; - } -} - -function existingValidPublishableKey(plist: Record): string | undefined { - const value = plist[SECRET_KEY]; - if (typeof value !== "string") return undefined; - return validatePublishableKey(value)?.value; -} - -function xmlEscape(value: string): string { - return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} - -function plistWithoutPublishableKey(plist: Record): Record { - return Object.fromEntries(Object.entries(plist).filter(([key]) => key !== SECRET_KEY)); -} - -function replaceOrInsertPublishableKey( - originalBytes: Uint8Array, - originalPlist: Record, - publishableKey: string, -): Uint8Array | undefined { - const source = decodeUTF8(originalBytes); - if (!source) return undefined; - const keyTag = /\s*CLERK_PUBLISHABLE_KEY\s*<\/key>/g; - const matches = [...source.matchAll(keyTag)]; - if (matches.length > 1) return undefined; - if (matches.length === 0 && Object.hasOwn(originalPlist, SECRET_KEY)) return undefined; - - let candidate: string; - if (matches.length === 1) { - const match = matches[0]!; - const keyEnd = match.index! + match[0].length; - const suffix = source.slice(keyEnd); - const stringValue = /^(\s*)([\s\S]*?)<\/string>/.exec(suffix); - const emptyStringValue = /^(\s*)/.exec(suffix); - if (stringValue) { - const replacement = `${stringValue[1]}${xmlEscape(publishableKey)}`; - candidate = `${source.slice(0, keyEnd)}${replacement}${suffix.slice(stringValue[0].length)}`; - } else if (emptyStringValue) { - const replacement = `${emptyStringValue[1]}${xmlEscape(publishableKey)}`; - candidate = `${source.slice(0, keyEnd)}${replacement}${suffix.slice(emptyStringValue[0].length)}`; - } else { - return undefined; - } - } else { - const closing = source.lastIndexOf(""); - if (closing === -1) return undefined; - const lineEnding = source.includes("\r\n") ? "\r\n" : "\n"; - const lineStart = source.lastIndexOf("\n", closing - 1) + 1; - const possibleIndent = source.slice(lineStart, closing); - if (/^[\t ]*$/.test(possibleIndent)) { - const childIndent = `${possibleIndent}${source.includes("\t") ? "\t" : " "}`; - const insertion = `${childIndent}${SECRET_KEY}${lineEnding}${childIndent}${xmlEscape(publishableKey)}${lineEnding}`; - candidate = `${source.slice(0, lineStart)}${insertion}${source.slice(lineStart)}`; - } else { - candidate = `${source.slice(0, closing)}${SECRET_KEY}${xmlEscape(publishableKey)}${source.slice(closing)}`; - } - } - - const candidateBytes = new TextEncoder().encode(candidate); - const candidatePlist = parseXMLPlist(candidateBytes); - if ( - !candidatePlist || - candidatePlist[SECRET_KEY] !== publishableKey || - !isDeepStrictEqual( - plistWithoutPublishableKey(originalPlist), - plistWithoutPublishableKey(candidatePlist), - ) - ) { - return undefined; - } - return candidateBytes; -} - -async function snapshotMatches(snapshot: FileSnapshot): Promise { - if (!snapshot.exists) { - try { - await lstat(snapshot.path); - return false; - } catch (error) { - return isFileSystemError(error, "ENOENT"); - } - } - return ( - snapshot.identity !== undefined && - snapshot.hash !== undefined && - (await fileMatchesIdentityAndHash(snapshot.path, snapshot.identity, snapshot.hash)) - ); -} - -async function fileMatchesHash( - path: string, - maximumBytes: number, - expectedHash: string, -): Promise { - const snapshot = await snapshotExistingFile(path, maximumBytes); - return snapshot?.hash === expectedHash; -} - -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/link remains atomic when directory fsync is unavailable. - } -} - -function runtimeKeySiblingPath(path: string): string { - return resolve(dirname(path), `.${basename(path)}.clerk-${process.pid}-${randomUUID()}.tmp`); -} - -type ClaimDestinationResult = { status: "claimed"; claim: ClaimedFile } | { status: "stale" }; - -async function claimDestination( - staged: StagedFile, - expectedIdentity: FileIdentity, - expectedHash: string, - claimPathIsSafe = staged.claimPathIsSafe, -): Promise { - const claimPath = runtimeKeySiblingPath(staged.targetPath); - if (staged.keyBearing && !(await claimPathIsSafe?.(claimPath))) { - throw new RuntimeKeyClaimProtectionError(claimPath); - } - try { - await rename(staged.targetPath, claimPath); - } catch (error) { - if (isFileSystemError(error, "ENOENT")) return { status: "stale" }; - throw error; - } - - const movedIdentity = await readPathIdentity(claimPath); - if (!movedIdentity) { - throw new RuntimeKeyFileOwnershipError( - "a claimed runtime-key destination could not be identified after it was moved", - ); - } - const claim: ClaimedFile = { path: claimPath, present: true, identity: movedIdentity }; - staged.recoveryClaims.push(claim); - if (staged.keyBearing && !(await claimPathIsSafe?.(claimPath))) { - await restoreClaimWithoutClobber(claim, staged.targetPath); - throw new RuntimeKeyClaimProtectionError(claimPath); - } - const movedExpectedFile = - identitiesMatch(movedIdentity, expectedIdentity) && - (await fileMatchesIdentityAndHash(claimPath, expectedIdentity, expectedHash)); - if (movedExpectedFile) return { status: "claimed", claim }; - - await restoreClaimWithoutClobber(claim, staged.targetPath); - return { status: "stale" }; -} - -async function stageFile( - snapshot: FileSnapshot, - content: Uint8Array, - options: StageFileOptions = {}, -): Promise { - const temporaryPath = runtimeKeySiblingPath(snapshot.path); - let created = false; - let openedIdentity: FileIdentity | undefined; - try { - const file = await open(temporaryPath, "wx", snapshot.mode); - created = true; - try { - const info = await file.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 }; - if (options.beforeWrite && !(await options.beforeWrite(temporaryPath))) { - throw new Error("temporary path is not safely ignored"); - } - await file.writeFile(content); - if (options.forceFailureAfterCreate) throw new Error("injected staging failure"); - await file.chmod(snapshot.mode); - await file.sync(); - } finally { - await file.close(); - } - const stagedIdentity = await readRegularFileIdentity(temporaryPath); - if ( - !openedIdentity || - !stagedIdentity || - !sameFile(stagedIdentity, openedIdentity) || - stagedIdentity.mode !== snapshot.mode || - !(await fileMatchesIdentityAndHash(temporaryPath, stagedIdentity, sha256(content))) - ) { - throw new Error("staged runtime-key file changed before it could be committed"); - } - return { - targetPath: snapshot.path, - temporaryPath, - candidateHash: sha256(content), - original: snapshot, - committed: false, - cleanupFailuresRemaining: options.cleanupFailures ?? 0, - keyBearing: options.keyBearing === true, - temporaryPresent: true, - stagedIdentity, - recoveryClaims: [], - claimPathIsSafe: options.claimPathIsSafe, - rollbackClaimPathIsSafe: options.rollbackClaimPathIsSafe, - }; - } catch { - if (created) { - try { - const currentIdentity = await readRegularFileIdentity(temporaryPath); - if (!openedIdentity || !currentIdentity || !sameFile(currentIdentity, openedIdentity)) { - throw new Error("the staged runtime-key path no longer identified this transaction"); - } - await rm(temporaryPath); - } catch { - throw new RuntimeKeyTemporaryFileCleanupError( - "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", - options.keyBearing === true, - ); - } - } - throw new Error("The runtime-key update could not be staged safely."); - } -} - -async function removeStagedTemporaryFile(staged: StagedFile): Promise { - if (!staged.temporaryPresent) return; - if (staged.cleanupFailuresRemaining > 0) { - staged.cleanupFailuresRemaining -= 1; - throw new RuntimeKeyTemporaryFileCleanupError( - "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", - staged.keyBearing, - ); - } - try { - const identity = await readRegularFileIdentity(staged.temporaryPath); - if (!identity || !sameFile(identity, staged.stagedIdentity)) { - throw new Error("the staged runtime-key path no longer identified this transaction"); - } - await rm(staged.temporaryPath); - staged.temporaryPresent = false; - } catch { - throw new RuntimeKeyTemporaryFileCleanupError( - "A temporary runtime-key file could not be removed. Inspect the LocalSecrets.plist directory for a .clerk-*.tmp file before continuing.", - staged.keyBearing, - ); - } -} - -async function committedCandidateMatches(staged: StagedFile): Promise { - const identity = staged.committedIdentity ?? staged.stagedIdentity; - return fileMatchesIdentityAndHash(staged.targetPath, identity, staged.candidateHash); -} - -async function claimedOriginalMatches(staged: StagedFile): Promise { - if (!staged.original.exists) return true; - return ( - staged.claimedOriginal?.present === true && - staged.original.hash !== undefined && - (await fileMatchesIdentityAndHash( - staged.claimedOriginal.path, - staged.claimedOriginal.identity, - staged.original.hash, - )) - ); -} - -async function commitStagedFile( - staged: StagedFile, - options: IOSRuntimeKeyApplyOptions = {}, -): Promise<"written" | "stale"> { - if (!(await snapshotMatches(staged.original))) return "stale"; - if (staged.original.exists) { - if (!staged.original.identity || !staged.original.hash) return "stale"; - const claimResult = await claimDestination( - staged, - staged.original.identity, - staged.original.hash, - ); - if (claimResult.status === "stale") return "stale"; - staged.claimedOriginal = claimResult.claim; - let installed = false; - try { - if ( - !(await claimedOriginalMatches(staged)) || - !(await fileMatchesIdentityAndHash( - staged.temporaryPath, - staged.stagedIdentity, - staged.candidateHash, - )) - ) { - throw new RuntimeKeyFileOwnershipError( - "a runtime-key transaction file changed before installation", - ); - } - await options.beforeStagedCommitInstall?.(staged.targetPath, staged.claimedOriginal.path); - if (!(await claimedOriginalMatches(staged))) { - throw new RuntimeKeyFileOwnershipError( - "the claimed runtime-key original changed before installation", - ); - } - const installResult = await linkOwnedSourceWithoutClobber( - staged.temporaryPath, - staged.stagedIdentity, - staged.candidateHash, - staged.targetPath, - ); - if (installResult === "occupied") { - await removeClaimedPath(staged.claimedOriginal, { - expectedHash: staged.original.hash, - expectedMode: staged.original.mode, - }); - await syncDirectory(dirname(staged.targetPath)); - return "stale"; - } - installed = true; - } catch (error) { - if (!installed && staged.claimedOriginal.present) { - try { - await restoreClaimWithoutClobber(staged.claimedOriginal, staged.targetPath); - } catch (restoreError) { - throw new RuntimeKeyFileOwnershipError( - "the claimed runtime-key original could not be restored after commit stopped", - { cause: new AggregateError([error, restoreError]) }, - ); - } - } - throw error; - } - } else { - const installResult = await linkOwnedSourceWithoutClobber( - staged.temporaryPath, - staged.stagedIdentity, - staged.candidateHash, - staged.targetPath, - ); - if (installResult === "occupied") return "stale"; - } - staged.committed = true; - staged.committedIdentity = staged.stagedIdentity; - await syncDirectory(dirname(staged.targetPath)); - if (!(await committedCandidateMatches(staged))) { - throw new RuntimeKeyFileOwnershipError( - "the committed runtime-key destination changed before it could be verified", - ); - } - await removeStagedTemporaryFile(staged); - return "written"; -} - -async function cleanupStagedFile(staged: StagedFile): Promise { - await removeStagedTemporaryFile(staged); -} - -async function releaseClaimedOriginals(stagedFiles: readonly StagedFile[]): Promise { - const withClaims = stagedFiles.filter( - (staged) => staged.committed && staged.claimedOriginal?.present, - ); - const states = await Promise.all( - withClaims.map(async (staged) => - Boolean( - staged.original.hash && - (await committedCandidateMatches(staged)) && - (await claimedOriginalMatches(staged)), - ), - ), - ); - if (!states.every(Boolean)) return false; - for (const staged of withClaims) { - await removeClaimedPath(staged.claimedOriginal!, { - expectedHash: staged.original.hash, - expectedMode: staged.original.mode, - }); - } - return true; -} - -async function discardClaimedOriginal(staged: StagedFile): Promise { - if (!staged.claimedOriginal?.present) return true; - if (!staged.original.hash || !(await claimedOriginalMatches(staged))) return false; - await removeClaimedPath(staged.claimedOriginal, { - expectedHash: staged.original.hash, - expectedMode: staged.original.mode, - }); - staged.committed = false; - return true; -} - -async function restoreCommittedFile( - staged: StagedFile, - options: IOSRuntimeKeyApplyOptions = {}, -): Promise<"restored" | "stale"> { - if (!(await committedCandidateMatches(staged))) return "stale"; - const candidateIdentity = staged.committedIdentity ?? staged.stagedIdentity; - const candidateClaimResult = await claimDestination( - staged, - candidateIdentity, - staged.candidateHash, - staged.rollbackClaimPathIsSafe, - ); - if (candidateClaimResult.status === "stale") return "stale"; - const candidateClaim = candidateClaimResult.claim; - if (!staged.original.exists) { - await removeClaimedPath(candidateClaim, { - expectedHash: staged.candidateHash, - expectedMode: staged.original.mode, - }); - await syncDirectory(dirname(staged.targetPath)); - staged.committed = false; - return "restored"; - } - - let rollback: StagedFile | undefined; - const originalClaim = staged.claimedOriginal?.present ? staged.claimedOriginal : undefined; - if (!originalClaim) { - rollback = await stageFile( - { - path: staged.targetPath, - exists: false, - mode: staged.original.mode, - }, - staged.original.bytes!, - { - keyBearing: staged.keyBearing, - beforeWrite: staged.claimPathIsSafe, - claimPathIsSafe: staged.claimPathIsSafe, - rollbackClaimPathIsSafe: staged.rollbackClaimPathIsSafe, - }, - ); - } - const originalSourcePath = originalClaim?.path ?? rollback!.temporaryPath; - const originalSource = await readRegularFileIdentityAndHash(originalSourcePath); - if ( - !originalSource || - (originalClaim && !sameFile(originalSource.identity, originalClaim.identity)) || - (rollback && !sameFile(originalSource.identity, rollback.stagedIdentity)) - ) { - throw new RuntimeKeyFileOwnershipError( - "the original runtime-key file could not be identified during rollback", - ); - } - let sourceInstalled = false; - try { - await options.beforeStagedRollbackInstall?.( - staged.targetPath, - originalSourcePath, - candidateClaim.path, - ); - const installResult = await linkOwnedSourceWithoutClobber( - originalSourcePath, - originalSource.identity, - originalSource.hash, - staged.targetPath, - ); - if (installResult === "occupied") { - await removeClaimedPath(candidateClaim, { - expectedHash: staged.candidateHash, - expectedMode: staged.original.mode, - }); - return "stale"; - } - sourceInstalled = true; - const restoredIdentity = await readRegularFileIdentity(staged.targetPath); - if ( - !restoredIdentity || - !sameFile(restoredIdentity, originalSource.identity) || - !(await fileMatchesIdentityAndHash( - staged.targetPath, - originalSource.identity, - originalSource.hash, - )) - ) { - throw new RuntimeKeyFileOwnershipError( - "the restored runtime-key destination did not match its recovery source", - ); - } - await removeClaimedPath(candidateClaim, { - expectedHash: staged.candidateHash, - expectedMode: staged.original.mode, - }); - if (originalClaim) { - await removeClaimedPath(originalClaim, { - expectedHash: originalSource.hash, - expectedMode: originalSource.identity.mode, - }); - } - staged.committed = false; - await syncDirectory(dirname(staged.targetPath)); - return "restored"; - } catch (error) { - if (!sourceInstalled) { - try { - const publicIdentity = await readPathIdentity(staged.targetPath); - if (!publicIdentity) { - await restoreClaimWithoutClobber(candidateClaim, staged.targetPath); - } else if (candidateClaim.present) { - await removeClaimedPath(candidateClaim, { - expectedHash: staged.candidateHash, - expectedMode: staged.original.mode, - }); - } - } catch (restoreError) { - throw new RuntimeKeyFileOwnershipError( - "the claimed runtime-key candidate could not be recovered after rollback stopped", - { cause: new AggregateError([error, restoreError]) }, - ); - } - } - throw error; - } finally { - if (rollback) await cleanupStagedFile(rollback); - } -} - -async function rollbackFiles( - stagedFiles: StagedFile[], - dependency: RollbackDependency, - preserveProtection = false, -): Promise { - let fullyRestored = true; - let payloadIsUnsafe = preserveProtection; - let cleanupFailure: RuntimeKeyTemporaryFileCleanupError | undefined; - for (const staged of stagedFiles) { - if (staged.targetPath !== dependency.payloadPath || staged.committed) continue; - try { - await cleanupStagedFile(staged); - } catch (error) { - fullyRestored = false; - payloadIsUnsafe = true; - if (error instanceof RuntimeKeyTemporaryFileCleanupError) { - cleanupFailure ??= error; - } - } - if (staged.keyBearing && staged.recoveryClaims.some((claim) => claim.present)) { - fullyRestored = false; - payloadIsUnsafe = true; - } - } - const payload = stagedFiles.find( - (staged) => staged.committed && staged.targetPath === dependency.payloadPath, - ); - const ordered = [ - ...(payload ? [payload] : []), - ...[...stagedFiles].reverse().filter((staged) => staged !== payload), - ]; - const keyBearingPaths = (): string[] => - stagedFiles - .filter((staged) => staged.keyBearing) - .flatMap((staged) => [ - ...(staged.committed ? [staged.targetPath] : []), - ...(staged.temporaryPresent ? [staged.temporaryPath] : []), - ...staged.recoveryClaims.filter((claim) => claim.present).map((claim) => claim.path), - ]); - for (const staged of ordered) { - if (!staged.committed) continue; - if (staged.targetPath === dependency.protectionPath && payloadIsUnsafe) { - fullyRestored = false; - continue; - } - try { - let restoreResult: "restored" | "stale"; - try { - restoreResult = await restoreCommittedFile(staged, dependency.options); - } catch (error) { - if ( - !(error instanceof RuntimeKeyClaimProtectionError) || - staged.targetPath !== dependency.payloadPath || - !(await ensureRollbackProtection(dependency, keyBearingPaths(), error.claimPath)) - ) { - throw error; - } - restoreResult = await restoreCommittedFile(staged, dependency.options); - } - if (restoreResult === "restored") { - continue; - } - if (staged.targetPath === dependency.protectionPath && !payloadIsUnsafe) { - // The payload is back to a non-key-bearing state, so retain a concurrent - // ignore-file edit instead of overwriting it merely to restore our guard. - if (!(await discardClaimedOriginal(staged))) fullyRestored = false; - continue; - } - } catch (error) { - if (error instanceof RuntimeKeyTemporaryFileCleanupError) { - cleanupFailure ??= error; - if (staged.targetPath === dependency.payloadPath) payloadIsUnsafe = true; - if (!staged.committed) continue; - } - // Continue so independent files are still restored when it is safe to do so. - } - fullyRestored = false; - if (staged.targetPath === dependency.payloadPath) payloadIsUnsafe = true; - } - if (payloadIsUnsafe) { - if (!(await ensureRollbackProtection(dependency, keyBearingPaths()))) { - fullyRestored = false; - } - } - if (cleanupFailure) throw cleanupFailure; - return fullyRestored; -} - -async function ensureRollbackProtection( - dependency: RollbackDependency, - keyBearingPaths: string[], - prospectiveClaimPath?: string, -): Promise { - const pathsAreProtected = - keyBearingPaths.length > 0 && - ( - await Promise.all( - keyBearingPaths.map(async (path) => - localSecretsIsIgnored( - dependency.root, - path, - path === dependency.payloadPath - ? dependency.protectionRules.at(-1)! - : dependency.protectionRules[0]!, - ), - ), - ) - ).every(Boolean); - const prospectiveClaimIsProtected = - prospectiveClaimPath == null || - (await localSecretsClaimPathIsIgnored( - dependency.root, - prospectiveClaimPath, - dependency.protectionRules[0]!, - )); - if (pathsAreProtected && prospectiveClaimIsProtected) { - return true; - } - - const current = await snapshotOptionalFile( - dependency.root, - dependency.protectionPath, - MAX_GITIGNORE_BYTES, - 0o644, - ); - if (!current) return false; - const currentText = current.exists ? decodeUTF8(current.bytes!) : ""; - if (currentText == null) return false; - - let protectedText = currentText; - for (const rule of dependency.protectionRules) { - protectedText = appendGitignoreRule(protectedText, rule); - } - const protection = await stageFile(current, new TextEncoder().encode(protectedText)); - try { - if ((await commitStagedFile(protection)) !== "written") return false; - if (!(await releaseClaimedOriginals([protection]))) return false; - } finally { - await cleanupStagedFile(protection); - } - - const protectedPaths = ( - await Promise.all( - keyBearingPaths.map(async (path) => - localSecretsIsIgnored( - dependency.root, - path, - path === dependency.payloadPath - ? dependency.protectionRules.at(-1)! - : dependency.protectionRules[0]!, - ), - ), - ) - ).every(Boolean); - return ( - protectedPaths && - (prospectiveClaimPath == null || - (await localSecretsClaimPathIsIgnored( - dependency.root, - prospectiveClaimPath, - dependency.protectionRules[0]!, - ))) - ); -} - -async function localSecretsIsIgnored( - root: string, - localSecretsPath: string, - rule: string, -): Promise { - if (await hasDescendantGitignore(root, localSecretsPath)) return false; - const gitignore = await snapshotExistingFile(resolve(root, ".gitignore"), MAX_GITIGNORE_BYTES); - const gitignoreText = gitignore?.bytes ? decodeUTF8(gitignore.bytes) : undefined; - if (gitignoreText == null || !gitignoreContainsRule(gitignoreText, rule)) return false; - const context = await coherentGitContext(root, [dirname(localSecretsPath)]); - if (context.state === "repository") { - const tracked = await gitPathExitCode( - context.root, - ["ls-files", "--error-unmatch"], - localSecretsPath, - ); - if (tracked !== 1) return false; - const ignored = await gitPathExitCode( - context.root, - ["check-ignore", "--quiet", "--no-index"], - localSecretsPath, - ); - return ignored === 0; - } - return ( - context.state === "not-repository" && - gitignoreRuleIsEffectiveWithoutRepository(gitignoreText, rule) - ); -} - -async function localSecretsClaimPathIsIgnored( - root: string, - claimPath: string, - rule: string, -): Promise { - if (await hasDescendantGitignore(root, claimPath)) return false; - const gitignore = await snapshotExistingFile(resolve(root, ".gitignore"), MAX_GITIGNORE_BYTES); - const gitignoreText = gitignore?.bytes ? decodeUTF8(gitignore.bytes) : undefined; - if (gitignoreText == null || !gitignoreContainsRule(gitignoreText, rule)) return false; - const context = await coherentGitContext(root, [dirname(claimPath)]); - if (context.state === "repository") { - const tracked = await prospectiveGitPathExitCode( - context.root, - ["ls-files", "--error-unmatch"], - claimPath, - ); - if (tracked !== 1) return false; - const ignored = await prospectiveGitPathExitCode( - context.root, - ["check-ignore", "--quiet", "--no-index"], - claimPath, - ); - return ignored === 0; - } - return ( - context.state === "not-repository" && - gitignoreRuleIsEffectiveWithoutRepository(gitignoreText, rule) - ); -} - -async function postWriteIsValid(plan: IOSRuntimeKeyPlan, publishableKey: string): Promise { - if (!plan.localSecretsPath || !plan.gitignoreRule) return false; - const localSecretsPath = resolve(plan.root, plan.localSecretsPath); - if (!(await pathIsSafelyWithinIOSRoot(plan.root, localSecretsPath))) return false; - const snapshot = await snapshotExistingFile(localSecretsPath, MAX_LOCAL_SECRETS_BYTES); - const plist = snapshot?.bytes ? parseXMLPlist(snapshot.bytes) : undefined; - const installedKey = - plist && typeof plist[SECRET_KEY] === "string" - ? validatePublishableKey(plist[SECRET_KEY])?.value - : undefined; - if (installedKey !== publishableKey) return false; - const gitBoundary = await coherentGitContext(plan.root, [ - resolve(plan.root, plan.projectPath), - dirname(localSecretsPath), - ]); - if (gitBoundary.state === "unknown" || gitBoundary.state === "mismatch") return false; - if (!(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule))) { - return false; - } - const inspection = await inspectIOSProject(plan.root, { target: plan.targetId }); - if ( - inspection.selection.state !== "selected" || - inspection.selection.projectPath !== plan.projectPath || - inspection.selection.targetId !== plan.targetId || - inspection.generatedProject != null - ) { - return false; - } - if (await generatedProjectKind(plan.root, resolve(plan.root, plan.projectPath))) return false; - const selectedTarget = inspection.appTargets.find( - (target) => target.id === plan.targetId && target.projectPath === plan.projectPath, - ); - if ( - !hasProvenRuntimeKeyWiring(selectedTarget) || - selectedTarget.runtimeKeySinks[0]?.path !== plan.localSecretsPath - ) { - return false; - } - if ( - !(await hasExclusiveRuntimeSinkOwnership( - plan.root, - plan.projectPath, - plan.targetId, - localSecretsPath, - )) - ) { - return false; - } - const selectedSource = inspection.localPublishableKey.source; - return ( - inspection.localPublishableKey.found && - !inspection.localPublishableKey.conflict && - selectedSource === plan.localSecretsPath - ); -} - -export async function applyIOSRuntimeKey( - plan: IOSRuntimeKeyPlan, - publishableKey: string, - options: IOSRuntimeKeyApplyOptions = {}, -): Promise { - if (plan.status === "blocked") return { status: "blocked", plan }; - if ( - plan.schemaVersion !== 1 || - plan.kind !== "clerk-ios-runtime-key" || - !plan.localSecretsPath || - !plan.gitignorePath || - !plan.gitignoreRule || - !plan.expectedLocalSecretsHash || - plan.expectedGitignoreHash === undefined || - typeof plan.changesGitignore !== "boolean" - ) { - return { - status: "blocked", - plan, - message: "The runtime-key plan is incomplete or unsupported.", - }; - } - - const validatedKey = validatePublishableKey(publishableKey); - if (!validatedKey) { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "invalid-publishable-key", - message: "A valid Clerk publishable key is required.", - }, - ], - }, - }; - } - if (validatedKey.instanceType !== "development") { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "production-publishable-key", - message: - "Automatic iOS runtime wiring accepts a development-instance publishable key only.", - }, - ], - }, - }; - } - const normalizedKey = validatedKey.value; - const targetGitignoreRule = plan.gitignoreRule; - - const prepared = await prepareRuntimeKeyPlan({ - root: plan.root, - projectPath: plan.projectPath, - targetId: plan.targetId, - localSecretsPath: plan.localSecretsPath, - }); - if (prepared.plan.status === "blocked") { - return { status: "blocked", plan: prepared.plan }; - } - if ( - prepared.plan.expectedLocalSecretsHash !== plan.expectedLocalSecretsHash || - prepared.plan.expectedGitignoreHash !== plan.expectedGitignoreHash - ) { - return { - status: "stale", - plan, - message: "LocalSecrets.plist or .gitignore changed after the plan was created.", - }; - } - const localSecretsSnapshot = prepared.localSecretsSnapshot!; - const gitignoreSnapshot = prepared.gitignoreSnapshot!; - const plist = prepared.plist!; - const existingKey = existingValidPublishableKey(plist); - if (existingKey && existingKey !== normalizedKey) { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "different-publishable-key", - message: - "LocalSecrets.plist already contains a different valid publishable key; it was preserved.", - }, - ], - }, - }; - } - - const needsPlistWrite = existingKey !== normalizedKey || plist[SECRET_KEY] !== normalizedKey; - const needsGitignoreWrite = prepared.gitignoreNeeded === true; - if (!needsPlistWrite && !needsGitignoreWrite) { - return { status: "satisfied", plan }; - } - - const plistCandidate = needsPlistWrite - ? replaceOrInsertPublishableKey(localSecretsSnapshot.bytes!, plist, normalizedKey) - : undefined; - if (needsPlistWrite && !plistCandidate) { - return { - status: "blocked", - plan: { - ...plan, - status: "blocked", - blockers: [ - { - code: "unsupported-local-secrets", - message: - "The publishable-key entry could not be updated without changing unrelated plist data.", - }, - ], - }, - }; - } - - const gitignoreText = gitignoreSnapshot.exists ? decodeUTF8(gitignoreSnapshot.bytes!) : ""; - if (gitignoreText == null) { - return { - status: "blocked", - plan, - message: ".gitignore is not valid UTF-8.", - }; - } - const temporaryRule = needsPlistWrite - ? gitignoreTemporaryRule(plan.root, localSecretsSnapshot.path) - : undefined; - let gitignoreCandidateText = gitignoreText; - if (temporaryRule) { - // This durable guard makes a crash-safe same-filesystem atomic write possible: no key - // bytes are written to the staged plist until Git proves this pattern is effective. - gitignoreCandidateText = appendGitignoreRule(gitignoreCandidateText, temporaryRule); - } - if (needsGitignoreWrite || temporaryRule) { - // Keep the exact target rule last so it is portable even before a repository exists. - gitignoreCandidateText = appendGitignoreRule(gitignoreCandidateText, plan.gitignoreRule); - } - const gitignoreCandidate = - gitignoreCandidateText !== gitignoreText - ? new TextEncoder().encode(gitignoreCandidateText) - : undefined; - const gitignoreCandidateHash = gitignoreCandidate - ? sha256(gitignoreCandidate) - : gitignoreSnapshot.hash; - - const stagedFiles: StagedFile[] = []; - const rollbackDependency: RollbackDependency = { - root: plan.root, - payloadPath: localSecretsSnapshot.path, - protectionPath: gitignoreSnapshot.path, - protectionRules: [...(temporaryRule ? [temporaryRule] : []), targetGitignoreRule], - options, - }; - const gitignoreCandidateIsCurrent = async (): Promise => - gitignoreCandidateHash != null && - (await fileMatchesHash(gitignoreSnapshot.path, MAX_GITIGNORE_BYTES, gitignoreCandidateHash)); - try { - if (gitignoreCandidate) { - stagedFiles.push( - await stageFile(gitignoreSnapshot, gitignoreCandidate, { - cleanupFailures: options.forceGitignoreCommitCleanupFailure === true ? 1 : 0, - }), - ); - } - - if ( - !(await snapshotMatches(localSecretsSnapshot)) || - !(await snapshotMatches(gitignoreSnapshot)) - ) { - return { - status: "stale", - plan, - message: "LocalSecrets.plist or .gitignore changed while the update was being prepared.", - }; - } - - const gitignoreStaged = stagedFiles.find( - (staged) => staged.targetPath === gitignoreSnapshot.path, - ); - if (gitignoreStaged) { - const result = await commitStagedFile(gitignoreStaged, options); - if (result === "stale") { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: "A target file changed while the runtime-key update was being committed.", - }; - } - } - - if (needsPlistWrite && !(await gitignoreCandidateIsCurrent())) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: ".gitignore changed after the crash-safe guard was committed.", - }; - } - - const localSecretsPath = resolve(plan.root, plan.localSecretsPath); - if (!(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule))) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The Git-ignore safety check failed and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "rolled-back", - plan, - message: "The Git-ignore safety check failed and the original files were restored.", - }; - } - - let plistStaged: StagedFile | undefined; - if (plistCandidate && temporaryRule) { - plistStaged = await stageFile(localSecretsSnapshot, plistCandidate, { - cleanupFailures: options.forcePlistCleanupFailureBeforeCommit === true ? 2 : 0, - forceFailureAfterCreate: options.forcePlistStageFailureAfterCreate === true, - keyBearing: true, - claimPathIsSafe: async (claimPath) => - (await gitignoreCandidateIsCurrent()) && - (await localSecretsClaimPathIsIgnored(plan.root, claimPath, temporaryRule)), - rollbackClaimPathIsSafe: async (claimPath) => - localSecretsClaimPathIsIgnored(plan.root, claimPath, temporaryRule), - beforeWrite: async (temporaryPath) => { - if (!(await gitignoreCandidateIsCurrent())) return false; - if (!(await localSecretsIsIgnored(plan.root, temporaryPath, temporaryRule))) return false; - await options.beforePlistWrite?.(temporaryPath); - return ( - (await gitignoreCandidateIsCurrent()) && - (await localSecretsIsIgnored(plan.root, temporaryPath, temporaryRule)) && - (await localSecretsIsIgnored(plan.root, localSecretsPath, targetGitignoreRule)) - ); - }, - }); - stagedFiles.push(plistStaged); - await options.afterPlistStage?.(); - if ( - !(await gitignoreCandidateIsCurrent()) || - !(await snapshotMatches(localSecretsSnapshot)) || - !(await localSecretsIsIgnored(plan.root, plistStaged.temporaryPath, temporaryRule)) || - !(await localSecretsIsIgnored(plan.root, localSecretsPath, plan.gitignoreRule)) - ) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: "A target file changed while the runtime-key update was being staged.", - }; - } - if (!(await gitignoreCandidateIsCurrent())) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: ".gitignore changed before LocalSecrets.plist was committed.", - }; - } - if ((await commitStagedFile(plistStaged, options)) === "stale") { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: "A target file changed while the runtime-key update was being committed.", - }; - } - await options.afterPlistCommit?.(); - if (!(await gitignoreCandidateIsCurrent())) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: ".gitignore changed after LocalSecrets.plist was committed.", - }; - } - } - - await options.beforePostWriteValidation?.(); - const valid = - options.forcePostWriteValidationFailure !== true && - (!needsPlistWrite || (await gitignoreCandidateIsCurrent())) && - (await postWriteIsValid(plan, normalizedKey)) && - (!needsPlistWrite || (await gitignoreCandidateIsCurrent())); - if (valid) { - const originalsReleased = await releaseClaimedOriginals(stagedFiles); - if (!originalsReleased) { - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update became stale and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "stale", - plan, - message: "A target file changed before the runtime-key update was finalized.", - }; - } - return { status: "applied", plan }; - } - - if (!(await rollbackFiles(stagedFiles, rollbackDependency))) { - throw new Error( - "The runtime-key update failed validation and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - return { - status: "rolled-back", - plan, - message: "The runtime-key update failed validation and the original files were restored.", - }; - } catch (error) { - if ( - !(await rollbackFiles( - stagedFiles, - rollbackDependency, - error instanceof RuntimeKeyTemporaryFileCleanupError && error.keyBearing, - )) - ) { - throw new Error( - "The runtime-key update failed and automatic rollback was incomplete. Git-ignore protection was retained when possible; inspect LocalSecrets.plist and .gitignore before retrying.", - ); - } - if (error instanceof RuntimeKeyTemporaryFileCleanupError) throw error; - return { - status: "rolled-back", - plan, - message: "The runtime-key update failed and the original files were restored.", - }; - } finally { - await Promise.all(stagedFiles.map(cleanupStagedFile)); - } -} From ba3944800d85131398428d784c4d1ce31fd526bc Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:22:18 -0400 Subject: [PATCH 16/29] refactor(init): share local Clerk package verification --- .../src/commands/init/ios/install-sdk.ts | 107 +----------------- 1 file changed, 2 insertions(+), 105 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 335ac0bd2..e967b6eea 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -5,6 +5,7 @@ import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcod import semver from "semver"; import { inspectIOSProject } from "./inspect.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; import { applyIOSExistingFileTransaction, hashIOSFileBytes, @@ -266,89 +267,6 @@ function projectParts( return { project, objects, projectObjectId, projectObject, targetObject }; } -function swiftManifestWithoutComments(source: string): string { - const chars = source.split(""); - const blank = (start: number, end: number) => { - for (let index = start; index < end; index += 1) { - if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " "; - } - }; - let index = 0; - while (index < chars.length) { - if (chars[index] === "/" && chars[index + 1] === "/") { - const start = index; - index += 2; - while (index < chars.length && chars[index] !== "\n") index += 1; - blank(start, index); - continue; - } - if (chars[index] === "/" && chars[index + 1] === "*") { - const start = index; - let depth = 1; - index += 2; - while (index < chars.length && depth > 0) { - if (chars[index] === "/" && chars[index + 1] === "*") { - depth += 1; - index += 2; - } else if (chars[index] === "*" && chars[index + 1] === "/") { - depth -= 1; - index += 2; - } else { - index += 1; - } - } - blank(start, index); - continue; - } - - let hashCount = 0; - while (chars[index + hashCount] === "#") hashCount += 1; - const quoteIndex = index + hashCount; - if (chars[quoteIndex] !== '"') { - index += 1; - continue; - } - const multiline = chars[quoteIndex + 1] === '"' && chars[quoteIndex + 2] === '"'; - index = quoteIndex + (multiline ? 3 : 1); - while (index < chars.length) { - const closesQuote = multiline - ? chars[index] === '"' && chars[index + 1] === '"' && chars[index + 2] === '"' - : chars[index] === '"'; - if (closesQuote) { - const quoteLength = multiline ? 3 : 1; - let closesHashes = true; - for (let hash = 0; hash < hashCount; hash += 1) { - if (chars[index + quoteLength + hash] !== "#") closesHashes = false; - } - if (closesHashes) { - index += quoteLength + hashCount; - break; - } - } - if (chars[index] === "\\") { - let escapeHashes = 0; - while (chars[index + 1 + escapeHashes] === "#") escapeHashes += 1; - if (escapeHashes === hashCount) { - index += 2 + escapeHashes; - continue; - } - } - index += 1; - } - } - return chars.join(""); -} - -async function safeDirectory(root: string, path: string): Promise { - if (!(await pathIsSafelyWithinIOSRoot(root, path))) return false; - try { - const info = await lstat(path); - return info.isDirectory() && !info.isSymbolicLink(); - } catch { - return false; - } -} - async function localReferenceIsClerk( root: string, projectPath: string, @@ -357,28 +275,7 @@ async function localReferenceIsClerk( const relativePath = asString(object.relativePath); if (!relativePath) return false; const packagePath = resolve(dirname(projectPath), relativePath); - 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 = swiftManifestWithoutComments(await manifest.text()); - const declaresClerkPackage = /\bPackage\s*\(\s*name\s*:\s*"Clerk"\s*,/s.test(source); - const declaresProduct = (name: IOSSDKProduct) => - new RegExp( - `\\.library\\s*\\(\\s*name\\s*:\\s*"${name}"\\s*,\\s*targets\\s*:\\s*\\[\\s*"${name}"\\s*\\]\\s*\\)`, - "s", - ).test(source); - return ( - declaresClerkPackage && - declaresProduct("ClerkKit") && - declaresProduct("ClerkKitUI") && - (await safeDirectory(root, resolve(packagePath, "Sources", "ClerkKit"))) && - (await safeDirectory(root, resolve(packagePath, "Sources", "ClerkKitUI"))) - ); - } catch { - return false; - } + return localClerkIOSPackageIsStructurallyValid(root, packagePath); } async function verifiedPackages( From b820d7070bc4c4237c7671cc8342db84d6d96cb9 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:48:10 -0400 Subject: [PATCH 17/29] fix(cli): fail closed on external workspace projects --- .../init/ios/associated-domain.test.ts | 19 ++++++++++ .../src/commands/init/ios/discovery.ts | 10 ++++- .../init/ios/entitlements-settings.test.ts | 16 ++++++++ .../src/commands/init/ios/inspect.test.ts | 37 ++++++++++++++++++- 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index 78a0f27f0..11e1aa128 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -451,6 +451,25 @@ struct MyApp: App { expect(await treeDigest(root)).toEqual(before); }); + test("fails closed when a workspace references an external Xcode project", async () => { + const root = await directFixture(); + const externalRoot = await temporaryRoot(); + await createIOSFixture(externalRoot, { includeKey: false }); + const workspace = join(root, "MyApp.xcworkspace"); + await mkdir(workspace); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + test("fails closed when exhaustive project discovery reaches its traversal bound", async () => { const root = await directFixture(); let directory = root; diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index ded5e9023..2deb1c5fb 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -651,7 +651,15 @@ export async function inspectWorkspace( embeddedProject ?? resolveWorkspaceLocation(base, location, workspaceDirectory); const safelyLocal = await pathIsSafelyWithinIOSRoot(root, absolutePath); projectPaths.add(safelyLocal ? relativeIOSPath(root, absolutePath) : absolutePath); - if (safelyLocal) localProjectPaths.add(absolutePath); + if (safelyLocal) { + localProjectPaths.add(absolutePath); + } else { + // Keep external references visible in the serializable workspace + // inventory, but do not treat the local project graph as exhaustive. + // Ownership-sensitive writers must fail closed because an external + // target could still reference the same file. + complete = false; + } } return { diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts index 8d43a2088..5387b09d1 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -403,6 +403,22 @@ describe("missing iOS entitlements build settings", () => { ); }); + test("fails closed when a workspace references an external Xcode project", async () => { + const root = await makeSynchronizedFixture(); + const externalRoot = await temporaryRoot(); + await createIOSFixture(externalRoot, { includeKey: false }); + const workspace = join(root, "MyApp.xcworkspace"); + await mkdir(workspace); + await writeFile( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( + "shared-synchronized-root", + ); + }); + test("fails closed when exhaustive project discovery reaches its traversal bound", async () => { const root = await makeSynchronizedFixture(); let directory = root; 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 55a83a097..8a6c0845f 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -3,7 +3,7 @@ import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcod 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"; +import { discoverIOSContainers, discoverLocalIOSProjects, inspectWorkspace } from "./discovery.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { recoverIOSFileTransactions } from "./file-transaction.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; @@ -1341,6 +1341,41 @@ let package = Package( expect(result.localProjectPaths).toEqual([]); }); + test.each(["absolute", "parent-relative", "symlink-escape"] as const)( + "keeps an external %s workspace project visible while marking ownership incomplete", + async (kind) => { + const root = await fixture({ workspace: true }); + const externalRoot = await fixture(); + const workspace = join(root, "MyApp.xcworkspace"); + const externalProject = join(externalRoot, "MyApp.xcodeproj"); + let location: string; + let visiblePath = externalProject; + if (kind === "absolute") { + location = `absolute:${externalProject}`; + } else if (kind === "parent-relative") { + location = `group:${relative(root, externalProject)}`; + } else { + visiblePath = join(root, "External.xcodeproj"); + await symlink(externalProject, visiblePath); + location = "group:External.xcodeproj"; + } + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + const result = await inspectWorkspace(root, workspace); + const inventory = await discoverLocalIOSProjects(root); + + expect(result.inspection.projectPaths).toContain(visiblePath); + expect(JSON.parse(JSON.stringify(result.inspection)).projectPaths).toContain(visiblePath); + expect(result.localProjectPaths).not.toContain(visiblePath); + expect(result.complete).toBe(false); + expect(inventory.projectPaths).not.toContain(visiblePath); + expect(inventory.complete).toBe(false); + }, + ); + test("does not guess when multiple application targets exist", async () => { const root = await fixture({ secondTarget: true }); const inspection = await inspectIOSProject(root); From 2860da24a5503173f0902b4a936c166f9c5c2259 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 22:53:37 -0400 Subject: [PATCH 18/29] fix(ios): include subprojects in mutation ownership --- .../commands/init/ios/direct-config.test.ts | 46 ++++++++++++++++++- .../src/commands/init/ios/discovery.ts | 4 ++ .../src/commands/init/ios/inspect.test.ts | 32 +++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index e15b8dfea..75f69fc53 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -14,7 +14,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, relative } from "node:path"; import { applyIOSDirectConfig, hasExactIOSSwiftUIAppContentRoot, @@ -71,12 +71,32 @@ async function expectNoTransactionArtifacts(root: string): Promise { async function updateProject(root: string, update: (objects: PbxObjects) => void): Promise { const path = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await updateProjectAt(path, update); +} + +async function updateProjectAt(path: string, update: (objects: PbxObjects) => void): Promise { const project = parsePbxProject(await readFile(path, "utf8")); const objects = (project as unknown as { objects: PbxObjects }).objects; update(objects); await writeFile(path, buildPbxProject(project)); } +async function addProjectReference( + ownerProjectPath: string, + referencedProjectPath: string, +): Promise { + await updateProjectAt(join(ownerProjectPath, "project.pbxproj"), (objects) => { + const referenceId = "464646464646464646464646"; + 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 shareEntrySourceWithSecondTarget(root: string): Promise { await updateProject(root, (objects) => { const phase = objects[IOS_FIXTURE_IDS.secondSourcesPhase]!; @@ -563,6 +583,30 @@ struct MyApp: App { ); }); + test("refuses mutation when an external referenced subproject owns the entry source", async () => { + const parentRoot = await temporaryRoot("clerk-ios-project-reference-"); + const root = join(parentRoot, "App"); + const siblingRoot = join(parentRoot, "Sibling"); + await createIOSFixture(root); + await createIOSFixture(siblingRoot, { clerkSDK: false, includeKey: false }); + const before = await readFile(appSourcePath(root)); + await updateProjectAt(join(siblingRoot, "MyApp.xcodeproj", "project.pbxproj"), (objects) => { + objects[IOS_FIXTURE_IDS.appTarget]!.productType = "com.apple.product-type.app-extension"; + objects[IOS_FIXTURE_IDS.appFile]!.path = relative( + siblingRoot, + join(root, "MyApp", "MyAppApp.swift"), + ); + objects[IOS_FIXTURE_IDS.appFile]!.sourceTree = "SOURCE_ROOT"; + }); + await addProjectReference(join(root, "MyApp.xcodeproj"), join(siblingRoot, "MyApp.xcodeproj")); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test("edits only the explicitly selected target", async () => { const root = await fixture({ secondTarget: true }); const mainBefore = await readFile(appSourcePath(root)); diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index 2deb1c5fb..bacac75d5 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -700,6 +700,10 @@ export async function discoverLocalIOSProjects( projectPaths.add(absoluteProjectPath); } + const referencedProjects = await discoverReferencedIOSProjects(root, projectPaths); + complete &&= referencedProjects.complete; + for (const projectPath of referencedProjects.projectPaths) projectPaths.add(projectPath); + return { projectPaths: [...projectPaths].sort(), 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 8a6c0845f..cc0c5c3c8 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -336,6 +336,38 @@ describe("discoverIOSContainers", () => { expect.objectContaining({ code: "xcode.incomplete-source-membership" }), ); }); + + test.each(["required", "workspace"] as const)( + "follows project references discovered from a %s project seed", + async (seedKind) => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-projects-")); + temporaryDirectories.push(root); + const seedRoot = join(root, "Pods", "SeedProject"); + const referencedRoot = join(root, "Pods", "ReferencedProject"); + await createIOSFixture(seedRoot, { includeKey: false }); + await createIOSFixture(referencedRoot, { includeKey: false }); + const seedProject = join(seedRoot, "MyApp.xcodeproj"); + const referencedProject = join(referencedRoot, "MyApp.xcodeproj"); + await addProjectReference(seedProject, referencedProject, "555555555555555555555555"); + + let requiredProjectPaths: string[] = []; + if (seedKind === "required") { + requiredProjectPaths = [relative(root, seedProject)]; + } else { + const workspace = join(root, "Seed.xcworkspace"); + await mkdir(workspace, { recursive: true }); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + } + + const inventory = await discoverLocalIOSProjects(root, requiredProjectPaths); + + expect(inventory.complete).toBe(true); + expect(inventory.projectPaths).toEqual([seedProject, referencedProject].sort()); + }, + ); }); describe("inspectIOSProject", () => { From a7c6115f50cd30ca0229111065b2ef82c293287f Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 09:32:43 -0400 Subject: [PATCH 19/29] refactor(ios): defer custom keys to app selection --- .../commands/init/ios/associated-domain.ts | 20 +- .../src/commands/init/ios/runtime-key.test.ts | 157 ------ .../src/commands/init/ios/runtime-key.ts | 509 ------------------ 3 files changed, 7 insertions(+), 679 deletions(-) delete mode 100644 packages/cli-core/src/commands/init/ios/runtime-key.test.ts delete mode 100644 packages/cli-core/src/commands/init/ios/runtime-key.ts diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 90e8a0b03..aaeb83b76 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -174,19 +174,13 @@ function runtimeFrontendHost( return undefined; } const source = key.source; - const connected = target.swift.configureCalls.some((call) => { - if (call.startupBinding !== "app-init") return false; - if (call.publishableKeyWiring === "inline-literal") { - return call.path === source && call.inlinePublishableKey?.state === "valid"; - } - if (call.publishableKeyWiring === "local-secrets-loader") { - return ( - call.localSecretsRuntimeBinding === "proven" && - target.runtimeKeySinks.some((sink) => sink.path === source) - ); - } - return call.publishableKeyWiring === "process-info-environment" && source.endsWith(".xcscheme"); - }); + const connected = target.swift.configureCalls.some( + (call) => + call.startupBinding === "app-init" && + call.publishableKeyWiring === "inline-literal" && + call.path === source && + call.inlinePublishableKey?.state === "valid", + ); return connected ? key.frontendApiHost : undefined; } diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts b/packages/cli-core/src/commands/init/ios/runtime-key.test.ts deleted file mode 100644 index 8ba3b0624..000000000 --- a/packages/cli-core/src/commands/init/ios/runtime-key.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rename, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import * as runtimeKey from "./runtime-key.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; - -const temporaryDirectories: string[] = []; - -function publishableKey(host: string, live = false): string { - return `pk_${live ? "live" : "test"}_${Buffer.from(`${host}$`).toString("base64")}`; -} - -function plistSource(key: string): string { - return ` - - - - CLERK_PUBLISHABLE_KEY - ${key} - - -`; -} - -async function fixture(key: string): Promise { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-key-verification-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { - complete: true, - includeKey: false, - localSecrets: true, - }); - await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), plistSource(key)); - return root; -} - -function options(root: string) { - return { - root, - projectPath: "MyApp.xcodeproj", - targetId: IOS_FIXTURE_IDS.appTarget, - }; -} - -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), - ); -}); - -describe("iOS LocalSecrets compatibility verification", () => { - test("exposes read-only verification without a LocalSecrets mutation API", () => { - expect(Object.keys(runtimeKey).sort()).toEqual([ - "planIOSRuntimeKeyVerification", - "verifyIOSRuntimeKey", - ]); - expect("planIOSRuntimeKey" in runtimeKey).toBe(false); - expect("applyIOSRuntimeKey" in runtimeKey).toBe(false); - }); - - test("compares the exact Quickstart runtime key without retaining or changing it", async () => { - const localKey = publishableKey("local.clerk.example"); - const linkedKey = publishableKey("linked.clerk.example"); - const root = await fixture(localKey); - const before = await treeDigest(root); - - const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); - const matched = await runtimeKey.verifyIOSRuntimeKey(plan, localKey); - const mismatched = await runtimeKey.verifyIOSRuntimeKey(plan, linkedKey); - - expect(plan.status).toBe("ready"); - expect(plan.localSecretsPath).toBe("MyApp/LocalSecrets.plist"); - expect(matched.status).toBe("matched"); - expect(mismatched.status).toBe("mismatched"); - expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(localKey); - expect(JSON.stringify({ plan, matched, mismatched })).not.toContain(linkedKey); - expect(await treeDigest(root)).toEqual(before); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - }); - - test("reports a changed LocalSecrets file as stale without repairing it", async () => { - const originalKey = publishableKey("original.clerk.example"); - const changedKey = publishableKey("changed.clerk.example"); - const root = await fixture(originalKey); - const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); - await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), plistSource(changedKey)); - const changedTree = await treeDigest(root); - - const result = await runtimeKey.verifyIOSRuntimeKey(plan, changedKey); - - expect(result.status).toBe("stale"); - expect(await treeDigest(root)).toEqual(changedTree); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - }); - - test("diagnoses an invalid Quickstart placeholder without filling it in", async () => { - const root = await fixture("pk_test_..."); - const before = await treeDigest(root); - - const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("invalid-publishable-key"); - expect(await treeDigest(root)).toEqual(before); - expect(await Bun.file(join(root, ".gitignore")).exists()).toBe(false); - }); - - test("does not generalize compatibility to a renamed secrets plist", async () => { - const key = publishableKey("renamed.clerk.example"); - const root = await fixture(key); - await rename( - join(root, "MyApp", "LocalSecrets.plist"), - join(root, "MyApp", "ApplicationSecrets.plist"), - ); - - const sourcePath = join(root, "MyApp", "MyAppApp.swift"); - const source = await Bun.file(sourcePath).text(); - await Bun.write( - sourcePath, - source.replace('forResource: "LocalSecrets"', 'forResource: "ApplicationSecrets"'), - ); - - const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const project = await Bun.file(projectPath).text(); - await Bun.write( - projectPath, - project.replace("path = LocalSecrets.plist;", "path = ApplicationSecrets.plist;"), - ); - const before = await treeDigest(root); - - const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); - - expect(plan.status).toBe("blocked"); - expect(plan.blockers[0]?.code).toBe("unproven-runtime-wiring"); - expect(await treeDigest(root)).toEqual(before); - }); - - test("rejects invalid and production linked keys without serializing them", async () => { - const localKey = publishableKey("development.clerk.example"); - const productionKey = publishableKey("production.clerk.example", true); - const invalidKey = "pk_test_..."; - const root = await fixture(localKey); - const plan = await runtimeKey.planIOSRuntimeKeyVerification(options(root)); - - const invalid = await runtimeKey.verifyIOSRuntimeKey(plan, invalidKey); - const production = await runtimeKey.verifyIOSRuntimeKey(plan, productionKey); - - expect(invalid.status).toBe("blocked"); - expect(invalid.plan.blockers[0]?.code).toBe("invalid-publishable-key"); - expect(production.status).toBe("blocked"); - expect(production.plan.blockers[0]?.code).toBe("production-publishable-key"); - expect(JSON.stringify({ invalid, production })).not.toContain(localKey); - expect(JSON.stringify({ invalid, production })).not.toContain(productionKey); - expect(JSON.stringify({ invalid, production })).not.toContain(invalidKey); - }); -}); diff --git a/packages/cli-core/src/commands/init/ios/runtime-key.ts b/packages/cli-core/src/commands/init/ios/runtime-key.ts deleted file mode 100644 index 5945eaede..000000000 --- a/packages/cli-core/src/commands/init/ios/runtime-key.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { lstat, readFile } from "node:fs/promises"; -import { basename, isAbsolute, resolve } from "node:path"; -import { decodePublishableKey } from "../../../lib/fapi.ts"; -import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; -import { - hashIOSFileBytes, - identitiesMatch, - readRegularFileIdentity, - type FileIdentity, -} from "./file-transaction.ts"; -import { inspectIOSProject } from "./inspect.ts"; -import { parseIOSPlist } from "./plist.ts"; -import type { IOSAppTarget } from "./types.ts"; - -const LOCAL_SECRETS_FILENAME = "LocalSecrets.plist"; -const MAX_LOCAL_SECRETS_BYTES = 1_000_000; -const PUBLISHABLE_KEY = "CLERK_PUBLISHABLE_KEY"; - -/** - * The one legacy compatibility shape that Clerk can prove without changing the - * user's source: the selected target's exact Quickstart-style LocalSecrets.plist - * runtime sink. - */ -export interface IOSRuntimeKeyVerificationOptions { - root: string; - /** Project-root-relative path selected by the iOS inspector. */ - projectPath: string; - targetId: string; - /** Optional exact path copied from a previous inspection result. */ - localSecretsPath?: string; -} - -export type IOSRuntimeKeyBlockerCode = - | "invalid-selection" - | "external-path" - | "target-not-found" - | "missing-local-secrets" - | "unreadable-local-secrets" - | "malformed-local-secrets" - | "invalid-publishable-key" - | "production-publishable-key" - | "unproven-runtime-wiring"; - -export interface IOSRuntimeKeyBlocker { - code: IOSRuntimeKeyBlockerCode; - message: string; -} - -/** - * A read-only, serializable proof of which legacy runtime sink should be - * compared after Clerk application linking. It never contains the locally - * stored publishable key or plist bytes. - */ -export interface IOSRuntimeKeyVerificationPlan { - schemaVersion: 1; - kind: "clerk-ios-runtime-key-verification"; - status: "ready" | "blocked"; - root: string; - projectPath: string; - targetId: string; - localSecretsPath?: string; - expectedLocalSecretsHash?: string; - blockers: IOSRuntimeKeyBlocker[]; -} - -export interface IOSRuntimeKeyVerificationResult { - status: "matched" | "mismatched" | "stale" | "blocked"; - plan: IOSRuntimeKeyVerificationPlan; -} - -interface LocalSecretsSnapshot { - path: string; - identity: FileIdentity; - hash: string; - publishableKey: string; - frontendApiHost: string; - instanceType: "development" | "production"; -} - -interface PreparedRuntimeKeyVerification { - plan: IOSRuntimeKeyVerificationPlan; - snapshot?: LocalSecretsSnapshot; -} - -function normalizedRelativePath(path: string): string { - return path.replaceAll("\\", "/"); -} - -function resolveRelativePath(root: string, path: string): string { - return resolve(root, ...normalizedRelativePath(path).split("/")); -} - -function makePlan( - options: IOSRuntimeKeyVerificationOptions, - root: string, - projectPath: string, - status: IOSRuntimeKeyVerificationPlan["status"], - details: Partial< - Pick< - IOSRuntimeKeyVerificationPlan, - "localSecretsPath" | "expectedLocalSecretsHash" | "blockers" - > - > = {}, -): IOSRuntimeKeyVerificationPlan { - return { - schemaVersion: 1, - kind: "clerk-ios-runtime-key-verification", - status, - root, - projectPath, - targetId: options.targetId, - localSecretsPath: details.localSecretsPath, - expectedLocalSecretsHash: details.expectedLocalSecretsHash, - blockers: details.blockers ?? [], - }; -} - -function blocked( - options: IOSRuntimeKeyVerificationOptions, - root: string, - projectPath: string, - code: IOSRuntimeKeyBlockerCode, - message: string, - source: Partial = {}, -): PreparedRuntimeKeyVerification { - return { - plan: makePlan(options, root, projectPath, "blocked", { - localSecretsPath: source.plan?.localSecretsPath, - expectedLocalSecretsHash: source.plan?.expectedLocalSecretsHash, - blockers: [{ code, message }], - }), - }; -} - -function blockedResult( - plan: IOSRuntimeKeyVerificationPlan, - code: IOSRuntimeKeyBlockerCode, - message: string, -): IOSRuntimeKeyVerificationResult { - return { - status: "blocked", - plan: { - schemaVersion: 1, - kind: "clerk-ios-runtime-key-verification", - status: "blocked", - root: plan.root, - projectPath: plan.projectPath, - targetId: plan.targetId, - localSecretsPath: plan.localSecretsPath, - expectedLocalSecretsHash: plan.expectedLocalSecretsHash, - blockers: [{ code, message }], - }, - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function decodeUTF8(bytes: Uint8Array): string | undefined { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return undefined; - } -} - -function parseLocalSecrets(bytes: Uint8Array): Record | undefined { - if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) return undefined; - const source = decodeUTF8(bytes); - if (!source) return undefined; - try { - const parsed = parseIOSPlist(source); - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} - -function validatePublishableKey(value: unknown): - | { - value: string; - frontendApiHost: string; - instanceType: "development" | "production"; - } - | undefined { - if (typeof value !== "string" || value === "" || value.trim() !== value) return undefined; - try { - const decoded = decodePublishableKey(value); - return { - value, - frontendApiHost: decoded.fapiHost, - instanceType: decoded.instanceType, - }; - } catch { - return undefined; - } -} - -async function readLocalSecretsSnapshot(path: string): Promise { - try { - const beforeRead = await readRegularFileIdentity(path); - const info = await lstat(path); - if ( - !beforeRead || - !info.isFile() || - info.isSymbolicLink() || - info.size > MAX_LOCAL_SECRETS_BYTES - ) { - return undefined; - } - - const bytes = new Uint8Array(await readFile(path)); - const afterRead = await readRegularFileIdentity(path); - if (!afterRead || !identitiesMatch(beforeRead, afterRead)) return undefined; - - const plist = parseLocalSecrets(bytes); - const decodedKey = validatePublishableKey(plist?.[PUBLISHABLE_KEY]); - if (!decodedKey || plist?.[PUBLISHABLE_KEY] !== decodedKey.value) return undefined; - - return { - path, - identity: afterRead, - hash: hashIOSFileBytes(bytes), - publishableKey: decodedKey.value, - frontendApiHost: decodedKey.frontendApiHost, - instanceType: decodedKey.instanceType, - }; - } catch { - return undefined; - } -} - -async function fileIsReadableXMLPlist(path: string): Promise { - try { - const beforeRead = await readRegularFileIdentity(path); - const info = await lstat(path); - if ( - !beforeRead || - !info.isFile() || - info.isSymbolicLink() || - info.size > MAX_LOCAL_SECRETS_BYTES - ) { - return false; - } - const bytes = new Uint8Array(await readFile(path)); - const afterRead = await readRegularFileIdentity(path); - return Boolean( - afterRead && identitiesMatch(beforeRead, afterRead) && parseLocalSecrets(bytes) !== undefined, - ); - } catch { - return false; - } -} - -async function snapshotStillMatches(snapshot: LocalSecretsSnapshot): Promise { - const current = await readLocalSecretsSnapshot(snapshot.path); - return Boolean( - current && - identitiesMatch(snapshot.identity, current.identity) && - current.hash === snapshot.hash, - ); -} - -function hasProvenQuickstartWiring(target: IOSAppTarget | undefined): target is IOSAppTarget { - if (!target || !target.swift.evidenceComplete) return false; - const entryPoint = target.swift.entryPoints[0]; - const configureCall = target.swift.configureCalls[0]; - const sink = target.runtimeKeySinks[0]; - return ( - target.swift.entryPoints.length === 1 && - target.swift.configureCalls.length === 1 && - configureCall?.publishableKeyWiring === "local-secrets-loader" && - configureCall.localSecretsRuntimeBinding === "proven" && - configureCall.startupBinding === "app-init" && - configureCall.path === entryPoint?.path && - target.swift.localSecretsRuntimeBindings.length === 1 && - target.runtimeKeySinks.length === 1 && - sink?.kind === "local-secrets-plist" && - basename(normalizedRelativePath(sink.path)) === LOCAL_SECRETS_FILENAME - ); -} - -async function prepareRuntimeKeyVerification( - options: IOSRuntimeKeyVerificationOptions, -): Promise { - const root = resolve(options.root); - const suppliedProjectPath = normalizedRelativePath(options.projectPath); - const suppliedLocalSecretsPath = - options.localSecretsPath == null ? undefined : normalizedRelativePath(options.localSecretsPath); - - if ( - !options.targetId || - !suppliedProjectPath || - isAbsolute(options.projectPath) || - isAbsolute(suppliedProjectPath) || - !suppliedProjectPath.endsWith(".xcodeproj") || - (suppliedLocalSecretsPath != null && - (isAbsolute(options.localSecretsPath!) || - isAbsolute(suppliedLocalSecretsPath) || - basename(suppliedLocalSecretsPath) !== LOCAL_SECRETS_FILENAME)) - ) { - return blocked( - options, - root, - suppliedProjectPath, - "invalid-selection", - "A root-relative Xcode project, application target, and optional exact LocalSecrets.plist path are required.", - ); - } - - const absoluteProjectPath = resolveRelativePath(root, suppliedProjectPath); - const projectPath = relativeIOSPath(root, absoluteProjectPath); - if (!(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath))) { - return blocked( - options, - root, - projectPath, - "external-path", - "The selected Xcode project resolves outside the project root.", - ); - } - - const inspection = await inspectIOSProject(root, { - target: options.targetId, - exhaustiveContainerDiscovery: true, - }); - if ( - inspection.selection.state !== "selected" || - inspection.selection.targetId !== options.targetId || - inspection.selection.projectPath !== projectPath - ) { - return blocked( - options, - root, - projectPath, - "target-not-found", - "The selected application target could not be verified in the selected Xcode project.", - ); - } - - const selectedTarget = inspection.appTargets.find( - (target) => target.id === options.targetId && target.projectPath === projectPath, - ); - if (!hasProvenQuickstartWiring(selectedTarget)) { - return blocked( - options, - root, - projectPath, - "unproven-runtime-wiring", - "Read-only compatibility requires the exact Quickstart LocalSecrets.plist loader and one proven startup configure call.", - ); - } - - const localSecretsRelativePath = normalizedRelativePath(selectedTarget.runtimeKeySinks[0]!.path); - const absoluteLocalSecretsPath = resolveRelativePath(root, localSecretsRelativePath); - const redactedSource = { - plan: makePlan(options, root, projectPath, "ready", { - localSecretsPath: localSecretsRelativePath, - }), - }; - - if ( - basename(localSecretsRelativePath) !== LOCAL_SECRETS_FILENAME || - (suppliedLocalSecretsPath != null && - resolveRelativePath(root, suppliedLocalSecretsPath) !== absoluteLocalSecretsPath) - ) { - return blocked( - options, - root, - projectPath, - "missing-local-secrets", - "The selected target does not use the exact supported LocalSecrets.plist runtime sink.", - redactedSource, - ); - } - if (!(await pathIsSafelyWithinIOSRoot(root, absoluteLocalSecretsPath))) { - return blocked( - options, - root, - projectPath, - "external-path", - "LocalSecrets.plist resolves outside the project root.", - redactedSource, - ); - } - - const snapshot = await readLocalSecretsSnapshot(absoluteLocalSecretsPath); - if (!snapshot) { - const code = (await fileIsReadableXMLPlist(absoluteLocalSecretsPath)) - ? "invalid-publishable-key" - : "malformed-local-secrets"; - return blocked( - options, - root, - projectPath, - code, - code === "invalid-publishable-key" - ? "The proven LocalSecrets.plist sink does not contain one canonical publishable key that can be verified." - : "LocalSecrets.plist must be an existing, regular, readable XML property-list dictionary.", - redactedSource, - ); - } - - const inspectedKey = inspection.localPublishableKey; - if ( - !inspectedKey.evidenceComplete || - !inspectedKey.found || - inspectedKey.conflict || - inspectedKey.source !== localSecretsRelativePath || - inspectedKey.frontendApiHost !== snapshot.frontendApiHost || - inspectedKey.instanceType !== snapshot.instanceType - ) { - return blocked( - options, - root, - projectPath, - "invalid-publishable-key", - "The proven LocalSecrets.plist sink is not the one unambiguous runtime publishable-key source for the selected target.", - redactedSource, - ); - } - - return { - plan: makePlan(options, root, projectPath, "ready", { - localSecretsPath: localSecretsRelativePath, - expectedLocalSecretsHash: snapshot.hash, - }), - snapshot, - }; -} - -/** - * Recognizes the existing Quickstart LocalSecrets pattern for post-link - * comparison. This function never proposes a plist or .gitignore write. - */ -export async function planIOSRuntimeKeyVerification( - options: IOSRuntimeKeyVerificationOptions, -): Promise { - return (await prepareRuntimeKeyVerification(options)).plan; -} - -/** Compares an already linked development key without retaining either key. */ -export async function verifyIOSRuntimeKey( - plan: IOSRuntimeKeyVerificationPlan, - linkedPublishableKey: string, -): Promise { - if (plan.status === "blocked") return { status: "blocked", plan }; - if ( - plan.schemaVersion !== 1 || - plan.kind !== "clerk-ios-runtime-key-verification" || - !plan.localSecretsPath || - basename(normalizedRelativePath(plan.localSecretsPath)) !== LOCAL_SECRETS_FILENAME || - !plan.expectedLocalSecretsHash - ) { - return blockedResult( - plan, - "invalid-selection", - "The runtime-key verification plan is incomplete or unsupported.", - ); - } - - const linkedKey = validatePublishableKey(linkedPublishableKey); - if (!linkedKey || linkedKey.value !== linkedPublishableKey) { - return blockedResult(plan, "invalid-publishable-key", "A valid publishable key is required."); - } - if (linkedKey.instanceType !== "development") { - return blockedResult( - plan, - "production-publishable-key", - "Runtime-key verification accepts a development-instance key only.", - ); - } - - const root = resolve(plan.root); - const absoluteLocalSecretsPath = resolveRelativePath(root, plan.localSecretsPath); - if (!(await pathIsSafelyWithinIOSRoot(root, absoluteLocalSecretsPath))) { - return blockedResult( - plan, - "external-path", - "LocalSecrets.plist resolves outside the project root.", - ); - } - const currentSnapshot = await readLocalSecretsSnapshot(absoluteLocalSecretsPath); - if (!currentSnapshot || currentSnapshot.hash !== plan.expectedLocalSecretsHash) { - return { status: "stale", plan }; - } - - const prepared = await prepareRuntimeKeyVerification({ - root, - projectPath: plan.projectPath, - targetId: plan.targetId, - localSecretsPath: plan.localSecretsPath, - }); - if (prepared.plan.status === "blocked" || !prepared.snapshot) { - return { status: "blocked", plan: prepared.plan }; - } - if ( - prepared.plan.expectedLocalSecretsHash !== plan.expectedLocalSecretsHash || - !(await snapshotStillMatches(prepared.snapshot)) - ) { - return { status: "stale", plan }; - } - - return { - status: prepared.snapshot.publishableKey === linkedKey.value ? "matched" : "mismatched", - plan, - }; -} From 71494c973f5a3079497112efe5e0fb330ee59be4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 09:41:30 -0400 Subject: [PATCH 20/29] test(ios): cover deferred custom domain setup --- .../init/ios/associated-domain.test.ts | 30 +++++++++++++++++++ .../commands/init/ios/associated-domain.ts | 7 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index 11e1aa128..ddf5cf0cf 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -530,6 +530,36 @@ struct MyApp: App { expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); }); + test("uses the selected application's host without inspecting a custom key source", async () => { + const root = await temporaryRoot(); + await createIOSFixture(root, { includeKey: false }); + await removeAssociatedDomains(root); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const source = `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: CustomKeyProvider.current) + } + + var body: some Scene { WindowGroup { Text("Hello") } } +} +`; + await writeFile(sourcePath, source); + + const plan = await planIOSAssociatedDomain(planOptions(root, true)); + const result = await applyIOSAssociatedDomain(plan, KEY); + + expect(plan).toMatchObject({ status: "ready", requiresPublishableKey: true }); + expect(result.status).toBe("applied"); + expect(await readFile(sourcePath, "utf8")).toBe(source); + expect(await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8")).toContain( + `webcredentials:${HOST}`, + ); + }); + test("returns stale when the selected target's inline key host changes after planning", async () => { const root = await directFixture(); const path = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index aaeb83b76..88392b9de 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -1015,7 +1015,12 @@ export async function validatePreparedIOSAssociatedDomain( return false; } const expectedHost = prepared.expectedDomain.slice("webcredentials:".length); - if (runtimeFrontendHost(inspection, target) !== expectedHost) return false; + if ( + !prepared.plan.requiresPublishableKey && + runtimeFrontendHost(inspection, target) !== expectedHost + ) { + return false; + } if (target.configurations.length === 0) return false; const files: EntitlementsFile[] = []; for (const configuration of target.configurations) { From 7deae23aa786bb5a5402636ac6b0638e1eaae3d0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 11:18:40 -0400 Subject: [PATCH 21/29] refactor(ios): consume explicit key state --- packages/cli-core/src/commands/init/ios/associated-domain.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 88392b9de..eceef84e7 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -170,9 +170,7 @@ function runtimeFrontendHost( target: IOSAppTarget, ): string | undefined { const key = inspection.localPublishableKey; - if (!key.evidenceComplete || !key.found || key.conflict || !key.source || !key.frontendApiHost) { - return undefined; - } + if (key.state !== "valid") return undefined; const source = key.source; const connected = target.swift.configureCalls.some( (call) => From 3c34ce5d2f49866590588670e46a114d5e028628 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 16:24:45 -0400 Subject: [PATCH 22/29] test(init): cover ambiguous Xcode ownership --- .../commands/init/ios/direct-config.test.ts | 29 ++++++++++++++++++ .../src/commands/init/ios/inspect.test.ts | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 75f69fc53..71c9383ad 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -607,6 +607,35 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("refuses mutation when the entry source has multiple PBX group parents", async () => { + const root = await fixture(); + const alternateGroupId = "565656565656565656565656"; + const alternateSourcePath = join(root, "Alternate", "MyAppApp.swift"); + const originalBefore = await readFile(appSourcePath(root)); + await mkdir(join(root, "Alternate")); + await writeFile(alternateSourcePath, originalBefore); + const alternateBefore = await readFile(alternateSourcePath); + await updateProject(root, (objects) => { + objects[alternateGroupId] = { + isa: "PBXGroup", + children: [IOS_FIXTURE_IDS.appFile], + path: "Alternate", + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.mainGroup]!.children = [ + alternateGroupId, + ...((objects[IOS_FIXTURE_IDS.mainGroup]!.children as string[]) ?? []), + ]; + }); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(originalBefore); + expect(await readFile(alternateSourcePath)).toEqual(alternateBefore); + }); + test("edits only the explicitly selected target", async () => { const root = await fixture({ secondTarget: true }); const mainBefore = await readFile(appSourcePath(root)); 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 cc0c5c3c8..1a9bc588e 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1408,6 +1408,36 @@ let package = Package( }, ); + test.each([ + ["decimal", "."], + ["hexadecimal", "."], + ])( + "keeps an external workspace project with a %s numeric entity visible and incomplete", + async (_label, encodedDot) => { + const root = await fixture({ workspace: true }); + const externalRoot = await fixture(); + const workspace = join(root, "MyApp.xcworkspace"); + const externalProject = join(externalRoot, "MyApp.xcodeproj"); + const encodedExternalProject = externalProject.replace( + /\.xcodeproj$/, + `${encodedDot}xcodeproj`, + ); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + ``, + ); + + const result = await inspectWorkspace(root, workspace); + const inventory = await discoverLocalIOSProjects(root); + + expect(result.inspection.projectPaths).toContain(externalProject); + expect(result.localProjectPaths).not.toContain(externalProject); + expect(result.complete).toBe(false); + expect(inventory.projectPaths).not.toContain(externalProject); + expect(inventory.complete).toBe(false); + }, + ); + test("does not guess when multiple application targets exist", async () => { const root = await fixture({ secondTarget: true }); const inspection = await inspectIOSProject(root); From 846d5d5e39701dfea9f904dbae25887429760c34 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 17:07:22 -0400 Subject: [PATCH 23/29] test(init): block interpolated Clerk configuration --- .../commands/init/ios/direct-config.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 71c9383ad..bb5eb7edd 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -223,6 +223,35 @@ struct MyApp: App { expect(hasExactIOSSwiftUIAppContentRoot(await source(interpolated))).toBe(false); }); + test("refuses mutation when a configure call executes inside string interpolation", async () => { + const root = await fixture(); + await replaceSource( + root, + String.raw`import SwiftUI +import ClerkKit + +@main +struct MyApp: App { + init() { + let diagnostic = "configured: \(Clerk.configure(publishableKey: "pk_test_hidden"))" + } + + var body: some Scene { WindowGroup { ContentView() } } +} +`, + ); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig(planOptions(root)); + const prepared = await prepareIOSDirectConfigMutation(plan, DEVELOPMENT_KEY); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(prepared.status).toBe("blocked"); + expect(prepared.mutation).toBeUndefined(); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test("configures a compact pristine app and is byte-idempotent", async () => { const root = await fixture(); const firstPlan = await planIOSDirectConfig(planOptions(root)); From 32b68256f3903ff67147ea56e6d5167dc455d5da Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 13:30:53 -0400 Subject: [PATCH 24/29] fix: validate Clerk SDK product compatibility --- .../src/commands/init/ios/install-sdk.test.ts | 78 +++++++++++++------ .../src/commands/init/ios/install-sdk.ts | 45 +++++++---- 2 files changed, 84 insertions(+), 39 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 5d930f400..b29ab818d 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -140,10 +140,14 @@ async function transformProject( function removeClerkSDK(graph: MutableGraph): void { graph.root.packageReferences = []; + removeClerkProductLinks(graph); + delete graph.objects[IOS_FIXTURE_IDS.clerkPackage]; +} + +function removeClerkProductLinks(graph: MutableGraph): void { graph.target.packageProductDependencies = []; graph.frameworks.files = []; for (const id of [ - IOS_FIXTURE_IDS.clerkPackage, IOS_FIXTURE_IDS.clerkKit, IOS_FIXTURE_IDS.clerkKitUI, IOS_FIXTURE_IDS.clerkKitBuildFile, @@ -295,7 +299,20 @@ describe("iOS Clerk SDK installer", () => { }); }); - test("raises an explicitly older requested version to the prebuilt AuthView floor", async () => { + test("raises an explicitly older requested version to the modern product floor", async () => { + const root = await fixture(); + await transformProject(root, removeClerkSDK); + + const plan = await planIOSSDKInstall({ + ...installOptions(root), + minimumVersion: "0.70.0", + }); + + expect(plan.minimumVersion).toBe(DEFAULT_CLERK_IOS_MINIMUM_VERSION); + expect(plan.minimumVersion).not.toBe("0.70.0"); + }); + + test("keeps the AuthView compatibility floor layered over the modern product floor", async () => { const root = await fixture(); await transformProject(root, removeClerkSDK); @@ -306,30 +323,47 @@ describe("iOS Clerk SDK installer", () => { }); expect(plan.minimumVersion).toBe(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); - expect(plan.minimumVersion).not.toBe("0.70.0"); + expect(plan.requirePrebuiltAuthCompatibility).toBe(true); }); - test("blocks a remote package pinned before the modern ClerkKitUI products", async () => { - const root = await fixture(); + test("blocks old remote constraints before adding modern ClerkKit products", async () => { + for (const includeClerkKitUI of [false, true]) { + const root = await fixture(); + await transformProject(root, (graph) => { + removeClerkProductLinks(graph); + graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { + kind: "versionRange", + minimumVersion: "0.70.0", + maximumVersion: "1.0.0", + }; + }); + const before = await treeDigest(root); + + const plan = await planIOSSDKInstall(installOptions(root, includeClerkKitUI)); + + expect(plan.status).toBe("blocked"); + expect(plan.actions).toEqual([]); + expect(plan.blockers[0]?.code).toBe("incompatible-sdk"); + expect(plan.blockers[0]?.message).toContain(DEFAULT_CLERK_IOS_MINIMUM_VERSION); + expect((await applyIOSSDKInstall(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("rejects a package constraint downgraded after a modern plan", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + const plan = await planIOSSDKInstall(installOptions(root)); + expect(plan.status).toBe("satisfied"); + expect(await validateIOSSDKInstallPostcondition(plan)).toBe(true); + await transformProject(root, (graph) => { graph.objects[IOS_FIXTURE_IDS.clerkPackage]!.requirement = { kind: "exactVersion", version: "0.70.0", }; }); - const before = await treeDigest(root); - const ordinaryPlan = await planIOSSDKInstall(installOptions(root, true)); - const prebuiltPlan = await planIOSSDKInstall({ - ...installOptions(root, true), - requirePrebuiltAuthCompatibility: true, - }); - - expect(ordinaryPlan.status).toBe("satisfied"); - expect(prebuiltPlan.status).toBe("blocked"); - expect(prebuiltPlan.blockers[0]?.code).toBe("incompatible-sdk"); - expect(prebuiltPlan.blockers[0]?.message).toContain(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); - expect(await treeDigest(root)).toEqual(before); + expect(await validateIOSSDKInstallPostcondition(plan)).toBe(false); }); test("requires a compatible resolved pin when a remote range permits older SDKs", async () => { @@ -342,10 +376,7 @@ describe("iOS Clerk SDK installer", () => { }; }); await writePackageResolution(oldRoot, "0.70.0"); - const oldPlan = await planIOSSDKInstall({ - ...installOptions(oldRoot, true), - requirePrebuiltAuthCompatibility: true, - }); + const oldPlan = await planIOSSDKInstall(installOptions(oldRoot, true)); expect(oldPlan.status).toBe("blocked"); expect(oldPlan.blockers[0]?.code).toBe("incompatible-sdk"); @@ -358,10 +389,7 @@ describe("iOS Clerk SDK installer", () => { }; }); await writePackageResolution(compatibleRoot, PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION); - const compatiblePlan = await planIOSSDKInstall({ - ...installOptions(compatibleRoot, true), - requirePrebuiltAuthCompatibility: true, - }); + const compatiblePlan = await planIOSSDKInstall(installOptions(compatibleRoot, true)); expect(compatiblePlan.status).toBe("satisfied"); expect(await validateIOSSDKInstallPostcondition(compatiblePlan)).toBe(true); }); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index e967b6eea..059fa0e2a 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -131,16 +131,23 @@ function validMinimumVersion(value: string): boolean { return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); } -function effectiveMinimumVersion(options: IOSSDKInstallOptions): string { - const requested = options.minimumVersion ?? DEFAULT_CLERK_IOS_MINIMUM_VERSION; +function requiredCompatibilityVersion(requirePrebuiltAuthCompatibility: boolean): string { if ( - !options.requirePrebuiltAuthCompatibility || - semver.valid(requested) == null || - semver.gte(requested, PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION) + requirePrebuiltAuthCompatibility && + semver.gt(PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION, DEFAULT_CLERK_IOS_MINIMUM_VERSION) ) { + return PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION; + } + return DEFAULT_CLERK_IOS_MINIMUM_VERSION; +} + +function effectiveMinimumVersion(options: IOSSDKInstallOptions): string { + const requested = options.minimumVersion ?? DEFAULT_CLERK_IOS_MINIMUM_VERSION; + const required = requiredCompatibilityVersion(options.requirePrebuiltAuthCompatibility === true); + if (semver.valid(requested) == null || semver.gte(requested, required)) { return requested; } - return PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION; + return required; } function supportedRemoteRequirement(value: unknown): boolean { @@ -449,16 +456,23 @@ async function resolvedClerkVersions( return { versions: [...new Set(versions)].sort(semver.compare), unreadable }; } -async function prebuiltAuthCompatibilityBlocker( +async function sdkProductCompatibilityBlocker( root: string, projectPath: string, inspection: Awaited>, selectedPackage: VerifiedPackage, objects: PbxObjects, + products: IOSSDKProduct[], + requirePrebuiltAuthCompatibility: boolean, ): Promise { - const requiredVersion = PREBUILT_AUTH_CLERK_IOS_MINIMUM_VERSION; - const prefix = `ClerkKitUI's documented native components require clerk-ios ${requiredVersion} or newer.`; + const requiredVersion = requiredCompatibilityVersion(requirePrebuiltAuthCompatibility); + const prefix = requirePrebuiltAuthCompatibility + ? `ClerkKitUI's documented native components require clerk-ios ${requiredVersion} or newer.` + : `${products.join(" and ")} ${products.length === 1 ? "requires" : "require"} clerk-ios ${requiredVersion} or newer.`; if (selectedPackage.kind === "local") { + // Local package verification already proves the modern ClerkKit products + // structurally. AuthView retains its stricter API-compatibility policy. + if (!requirePrebuiltAuthCompatibility) return undefined; return { code: "incompatible-sdk", message: `${prefix} A local package's compiled target membership cannot be proven without executing its Package.swift manifest, so no source was changed. Use a compatible remote clerk-ios package or integrate AuthView manually.`, @@ -1053,6 +1067,7 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise !graphs.get(productName)?.buildFileId, ); @@ -1277,13 +1293,14 @@ export async function validateIOSSDKInstallPostcondition( return false; } if ( - plan.requirePrebuiltAuthCompatibility && - (await prebuiltAuthCompatibilityBlocker( + (await sdkProductCompatibilityBlocker( plan.root, plan.projectPath, inspection, selectedPackage, parts.objects, + plan.products, + plan.requirePrebuiltAuthCompatibility === true, )) != null ) { return false; From 0bf8a25e3a8933e20a2267e1a08a6e1e7fe2edeb Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 16:32:06 -0400 Subject: [PATCH 25/29] test(ios): block malformed source ownership --- .../commands/init/ios/direct-config.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index bb5eb7edd..e1b20fbb7 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -690,6 +690,28 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test.each(["build-phases", "source-phase-files"] as const)( + "refuses mutation when shared-source ownership uses malformed %s", + async (collection) => { + const root = await fixture({ secondTarget: true }); + const before = await readFile(appSourcePath(root)); + await shareEntrySourceWithSecondTarget(root); + await updateProject(root, (objects) => { + if (collection === "build-phases") { + objects[IOS_FIXTURE_IDS.secondTarget]!.buildPhases = IOS_FIXTURE_IDS.secondSourcesPhase; + } else { + objects[IOS_FIXTURE_IDS.secondSourcesPhase]!.files = SHARED_ENTRY_BUILD_FILE_ID; + } + }); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }, + ); + test("refuses an entry source aliased into another target through a hard link", async () => { const root = await fixture({ secondTarget: true }); const aliasPath = join(root, "AdminApp", "SharedApp.swift"); From fdeec11284acd0b4e102be11b7983e3ed4f893a0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 17:33:27 -0400 Subject: [PATCH 26/29] fix(ios): fail closed on malformed SDK filters --- .../src/commands/init/ios/install-sdk.test.ts | 20 +++++++++++++++++++ .../src/commands/init/ios/install-sdk.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index b29ab818d..f9d832e4f 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -578,6 +578,26 @@ describe("iOS Clerk SDK installer", () => { expect((await planIOSSDKInstall(installOptions(root))).status).toBe("satisfied"); }); + test.each<{ description: string; platformFilter: unknown }>([ + { description: "array-valued", platformFilter: ["ios"] }, + { description: "object-valued", platformFilter: { filter: "ios" } }, + ])( + "blocks an $description singular platform filter without writing", + async ({ platformFilter }) => { + const root = await fixture({ clerkSDK: "core-only" }); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = platformFilter; + }); + const before = await readFile(pbxprojPath(root)); + + const plan = await planIOSSDKInstall(installOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-project"); + expect(await readFile(pbxprojPath(root))).toEqual(before); + }, + ); + test("reuses a verified local package and canonical remote URL variants", async () => { const localRoot = await fixture(); await mkdir(join(localRoot, "LocalClerk", "Sources", "ClerkKit"), { recursive: true }); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 059fa0e2a..37f05ae5f 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -550,7 +550,11 @@ function buildFileIOSApplicability(object: PbxObject): { ) { return { applies: false, recognized: false }; } - const platformFilter = asString(object.platformFilter); + const rawPlatformFilter = object.platformFilter; + const platformFilter = asString(rawPlatformFilter); + 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))) { From c7b871a70e27c59472a12d18dc2b7684695ae0bf Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 17:36:31 -0400 Subject: [PATCH 27/29] test(ios): block malformed synchronized ownership --- .../commands/init/ios/direct-config.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index e1b20fbb7..9f3d5c66e 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -712,6 +712,26 @@ struct MyApp: App { }, ); + test("refuses mutation when malformed synchronized membership owns the entry source", async () => { + const root = await fixture({ secondTarget: true }); + const before = await readFile(appSourcePath(root)); + const synchronizedRootId = "616161616161616161616161"; + await updateProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "MyApp", + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.secondTarget]!.fileSystemSynchronizedGroups = synchronizedRootId; + }); + + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test("refuses an entry source aliased into another target through a hard link", async () => { const root = await fixture({ secondTarget: true }); const aliasPath = join(root, "AdminApp", "SharedApp.swift"); From cb349becad57f123d8088ec6ab4000afafb4177a Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 18:29:47 -0400 Subject: [PATCH 28/29] fix(init): bound associated domain reads --- .../init/ios/associated-domain.test.ts | 32 +++++++++ .../commands/init/ios/associated-domain.ts | 69 ++++++++++++------- .../src/commands/init/ios/bounded-file.ts | 8 ++- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index ddf5cf0cf..f9f04eb74 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -606,6 +606,38 @@ struct MyApp: App { expect(await treeDigest(linked)).toEqual(before); }); + test("blocks an oversized entitlements file without changing it", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const plan = await planIOSAssociatedDomain(planOptions(root)); + const result = await applyIOSAssociatedDomain(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-entitlements"); + expect(result.status).toBe("blocked"); + expect(await readFile(path)).toEqual(oversized); + }); + + test("returns stale without touching an entitlements file that grows beyond the limit", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + await removeAssociatedDomains(root); + const plan = await planIOSAssociatedDomain(planOptions(root)); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const prepared = await prepareIOSAssociatedDomainMutation(plan); + const result = await applyIOSAssociatedDomain(plan); + + expect(plan.status).toBe("ready"); + expect(prepared.status).toBe("stale"); + expect(result.status).toBe("stale"); + expect(await readFile(path)).toEqual(oversized); + }); + test("blocks an entity-encoded Associated Domains key without rewriting it", async () => { const root = await directFixture(); const path = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index eceef84e7..afa700e17 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -2,6 +2,7 @@ import { lstat, readFile, realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import { discoverLocalIOSProjects, @@ -249,20 +250,46 @@ async function inspectEntitlementsFile( }; } - try { - const info = await lstat(absolutePath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_ENTITLEMENTS_BYTES) { - return { - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath( - root, - absolutePath, - )} must be a regular, non-symlink XML plist no larger than 1 MB.`, - ), - }; + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "not-regular" || file.status === "too-large") { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + if (file.status !== "ok") { + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_ENTITLEMENTS_BYTES) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + } catch { + // Preserve the unreadable classification below when the current path + // cannot explain the bounded reader's failure. } - const bytes = new Uint8Array(await readFile(absolutePath)); + return { + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } + + try { + const bytes = file.bytes; if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { return { blocker: blocker( @@ -330,7 +357,7 @@ async function inspectEntitlementsFile( relativePath: relativeIOSPath(root, absolutePath), bytes, hash: hashIOSFileBytes(bytes), - mode: info.mode & 0o7777, + mode: file.mode, source, bom, domains, @@ -866,17 +893,9 @@ export async function prepareIOSAssociatedDomainMutation( } continue; } - try { - if (!plannedFile.expectedHash) return { status: "blocked", plan }; - const info = await lstat(absolutePath); - if ( - !info.isFile() || - info.isSymbolicLink() || - hashIOSFileBytes(await readFile(absolutePath)) !== plannedFile.expectedHash - ) { - return { status: "stale", plan }; - } - } catch { + if (!plannedFile.expectedHash) return { status: "blocked", plan }; + const current = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (current.status !== "ok" || hashIOSFileBytes(current.bytes) !== plannedFile.expectedHash) { return { status: "stale", plan }; } } diff --git a/packages/cli-core/src/commands/init/ios/bounded-file.ts b/packages/cli-core/src/commands/init/ios/bounded-file.ts index 802bfb1c3..1311fc43d 100644 --- a/packages/cli-core/src/commands/init/ios/bounded-file.ts +++ b/packages/cli-core/src/commands/init/ios/bounded-file.ts @@ -4,7 +4,7 @@ import { open } from "node:fs/promises"; const READ_CHUNK_BYTES = 64 * 1024; export type BoundedRegularFileReadResult = - | { status: "ok"; bytes: Uint8Array } + | { status: "ok"; bytes: Uint8Array; mode: number } | { status: "missing" | "not-regular" | "too-large" | "unreadable" }; function missingPath(error: unknown): boolean { @@ -49,7 +49,11 @@ export async function readBoundedRegularFile( chunks.push(chunk.subarray(0, bytesRead)); } - return { status: "ok", bytes: Buffer.concat(chunks, totalBytes) }; + return { + status: "ok", + bytes: Buffer.concat(chunks, totalBytes), + mode: info.mode & 0o7777, + }; } catch { return { status: "unreadable" }; } finally { From f56efca77fbf11f2223316342e81bb93193aa9db Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 19:02:09 -0400 Subject: [PATCH 29/29] docs(changeset): add safe local iOS setup --- .changeset/ios-local-setup.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ios-local-setup.md diff --git a/.changeset/ios-local-setup.md b/.changeset/ios-local-setup.md new file mode 100644 index 000000000..eec9dc82a --- /dev/null +++ b/.changeset/ios-local-setup.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add safe local Clerk setup for supported iOS projects.