diff --git a/.typos.toml b/.typos.toml index 6d0ccf5997..bf2ec9759c 100644 --- a/.typos.toml +++ b/.typos.toml @@ -78,4 +78,6 @@ ALS = "ALS" # AsyncLocalStorage abbreviation (used in CLI perf-tracking spec) UDID = "UDID" # Apple's "Unique Device IDentifier" — distinct from UUID, used in Developer Portal walkthroughs. unparseable = "unparseable" # Valid English synonym of "unparsable". CIPS = "CIPS" # real App Store Connect session role value (ASC key helper) +ITMS = "ITMS" # App Store Connect upload error-code prefix (ITMS-90704, ITMS-90474, ...) cited in iOS prescan findings +loca = "loca" # localtunnel host suffix *.loca.lt detected by the iOS capacitor-server-url check # Add more project-specific terms as needed diff --git a/cli/src/build/mobileprovision-parser.ts b/cli/src/build/mobileprovision-parser.ts index b1167c5147..8436fc4bb4 100644 --- a/cli/src/build/mobileprovision-parser.ts +++ b/cli/src/build/mobileprovision-parser.ts @@ -27,8 +27,22 @@ export interface MobileprovisionDetail extends MobileprovisionInfo { profileType: 'app_store' | 'ad_hoc' | 'development' | 'enterprise' | 'unknown' /** SHA1 (40-char lowercase hex) of each DeveloperCertificate embedded in the profile */ certificateSha1s: string[] + /** + * The capability-bearing keys parsed from the profile's `Entitlements` + * dict, keyed by entitlement name. String/bool entitlements map to their value; + * array entitlements (application-groups, associated-domains, keychain-access-groups, + * iCloud container ids, etc.) map to the list of `` members. Only keys + * actually present in the profile are included, so a caller can both test + * presence (`key in profileEntitlements`) and read the granted value/members. + * No credential material is included — these are capability KEY names + their + * declared identifiers, the same data the entitlement-coverage checks surface. + */ + profileEntitlements: ProfileEntitlements } +export type ProfileEntitlementValue = string | string[] | boolean +export type ProfileEntitlements = Record + export function parseMobileprovision(filePath: string): MobileprovisionInfo { const data = readFileSync(filePath) return parseMobileprovisionBuffer(data, filePath) @@ -103,6 +117,7 @@ export function parseMobileprovisionBufferDetailed(data: Buffer, source = '` children of `...` block text. */ +function arrayStringMembers(arrayXml: string): string[] { + return Array.from(arrayXml.matchAll(/([\s\S]*?)<\/string>/g), m => m[1].trim()) +} + +/** + * Parse the capability keys from the profile's first `Entitlements` + * dict (one-level capture, mirroring extractNestedPlistValue). EVERY key present + * in the dict is read generically by its sibling value tag (string / bool / array), + * so the profile side is symmetric with the app side — a granted capability outside + * any fixed allowlist is recorded, not silently treated as "missing" (which would + * false-positive in the entitlements-vs-profile coverage check). Auto-managed keys + * (application-identifier, *.team-identifier) are skipped. Only keys actually present + * are added, so a missing capability is absent (not a false value). Never throws — + * returns {} when there is no Entitlements dict. + */ +function extractProfileEntitlements(xml: string): ProfileEntitlements { + const dict = xml.match(/Entitlements<\/key>\s*([\s\S]*?)<\/dict>/)?.[1] + if (dict === undefined) + return {} + const out: ProfileEntitlements = {} + // Each K followed by its value: a self-closing /, an + // ... (non-greedy, first close wins), or a scalar V. + const re = /([\s\S]*?)<\/key>\s*(?:<(true|false)\s*\/>|([\s\S]*?)<\/array>|<([a-z]+)>([\s\S]*?)<\/\4>)/g + for (const m of dict.matchAll(re)) { + const key = m[1].trim() + if (key in out || isAutoManagedEntitlementKey(key)) + continue + if (m[2] !== undefined) + out[key] = m[2] === 'true' + else if (m[3] !== undefined) + out[key] = arrayStringMembers(m[3]) + else if (m[4] === 'string') + out[key] = m[5].trim() + // Other scalar kinds (integer/real/date/data) are not capability-bearing and + // are intentionally not surfaced; their presence is still implied by the key. + else + out[key] = m[5].trim() + } + return out +} + function extractPlistValue(xml: string, key: string, valueTag: string = 'string'): string | null { const tag = escapeRegex(valueTag) const regex = new RegExp(`${escapeRegex(key)}\\s*<${tag}>([^<]*)`) diff --git a/cli/src/build/onboarding/ios/flow.ts b/cli/src/build/onboarding/ios/flow.ts index 627bde2770..e6c35aef8b 100644 --- a/cli/src/build/onboarding/ios/flow.ts +++ b/cli/src/build/onboarding/ios/flow.ts @@ -772,6 +772,9 @@ function synthesizeProfileFromAscSummary( ? 'ad_hoc' : 'unknown') as DiscoveredProfile['profileType'], certificateSha1s: [identity.sha1], + // No entitlements dict in an ASC profile summary — the field is required on + // MobileprovisionDetail, so populate it with an empty map. + profileEntitlements: {}, profileBase64: summary.profileContent, } as DiscoveredProfile & { profileBase64: string } } @@ -3086,6 +3089,7 @@ export async function runIosEffect( expirationDate: detail.expirationDate, profileType: detail.profileType as DiscoveredProfile['profileType'], certificateSha1s: detail.certificateSha1s, + profileEntitlements: detail.profileEntitlements, } const matches = deps.carried?.importMatches ?? [] const injectedMatches = matches.map(m => m.identity.sha1 === chosenIdentity.sha1 diff --git a/cli/src/build/prescan/capacitor-version.ts b/cli/src/build/prescan/capacitor-version.ts new file mode 100644 index 0000000000..bdc494dc41 --- /dev/null +++ b/cli/src/build/prescan/capacitor-version.ts @@ -0,0 +1,31 @@ +// src/build/prescan/capacitor-version.ts +import { join } from 'node:path' +import { readTextIfExists } from './gradle' + +/** + * Major version of the Capacitor runtime declared in package.json, or null. + * + * Checks `@capacitor/core` first (the platform-agnostic runtime), then the + * platform packages `@capacitor/ios` and `@capacitor/android`, so both the iOS + * and Android prescan checks resolve the same major from one shared place. + * Reads dev+prod dependencies, returns null on a missing/malformed package.json + * (never throws). + */ +export function capacitorMajor(projectDir: string): number | null { + const raw = readTextIfExists(join(projectDir, 'package.json')) + if (raw === null) + return null + let deps: Record + try { + const pkg = JSON.parse(raw) as { dependencies?: Record, devDependencies?: Record } + deps = { ...pkg.devDependencies, ...pkg.dependencies } + } + catch { + return null + } + const range = deps['@capacitor/core'] ?? deps['@capacitor/ios'] ?? deps['@capacitor/android'] + if (!range) + return null + const m = range.match(/(\d+)/) + return m ? Number(m[1]) : null +} diff --git a/cli/src/build/prescan/checks/android-project.ts b/cli/src/build/prescan/checks/android-project.ts index 470a8993b9..1a9ee31815 100644 --- a/cli/src/build/prescan/checks/android-project.ts +++ b/cli/src/build/prescan/checks/android-project.ts @@ -11,6 +11,7 @@ import { stripGradleComments, } from '../gradle' import { readAndroidManifest, stripXmlComments } from '../manifest' +import { capacitorMajor } from '../capacitor-version' import { willUploadToPlay } from '../upload-intent' function hasCordovaPlugins(ctx: ScanContext): boolean { @@ -535,26 +536,6 @@ export const targetSdkPlay: PrescanCheck = { const CAP_MINSDK_FLOORS: Record = { 6: 22, 7: 23, 8: 24 } const CAP_MINSDK_DEFAULT = 24 -/** Major version of @capacitor/core or @capacitor/android from package.json, or null. */ -function capacitorMajor(projectDir: string): number | null { - const raw = readTextIfExists(join(projectDir, 'package.json')) - if (raw === null) - return null - let deps: Record - try { - const pkg = JSON.parse(raw) as { dependencies?: Record, devDependencies?: Record } - deps = { ...pkg.devDependencies, ...pkg.dependencies } - } - catch { - return null - } - const range = deps['@capacitor/core'] ?? deps['@capacitor/android'] - if (!range) - return null - const m = range.match(/(\d+)/) - return m ? Number(m[1]) : null -} - function capacitorMinSdkFloor(major: number): number { return CAP_MINSDK_FLOORS[major] ?? CAP_MINSDK_DEFAULT } diff --git a/cli/src/build/prescan/checks/ios-capacitor-config.ts b/cli/src/build/prescan/checks/ios-capacitor-config.ts new file mode 100644 index 0000000000..bd8a6a9ff6 --- /dev/null +++ b/cli/src/build/prescan/checks/ios-capacitor-config.ts @@ -0,0 +1,118 @@ +// src/build/prescan/checks/ios-capacitor-config.ts +// +// §2.D — Capacitor config checks. These read only ctx.config (a passthrough +// zod object, so server.* is reached via safe optional access — no parser +// needed). Upload intent escalates severity for the server.url check. All +// findings name only config values, never credential material. +import type { Finding, PrescanCheck, ScanContext } from '../types' +import { willUploadToAppStore } from '../upload-intent' + +// ctx.config is `.passthrough()`, so the server block is not in the static type. +// Read it through a narrow shape rather than re-parsing the config. +interface CapServer { + url?: unknown + cleartext?: unknown + allowNavigation?: unknown +} +function serverOf(ctx: ScanContext): CapServer | undefined { + return (ctx.config as { server?: CapServer } | undefined)?.server +} +function serverUrlOf(ctx: ScanContext): string | null { + const url = serverOf(ctx)?.url + return typeof url === 'string' && url !== '' ? url : null +} + +// Dev-only markers that make a shipped server.url unambiguously a live-reload +// leftover rather than a deliberate production host. +const RFC1918_RE = /^https?:\/\/(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.)/i +const LOCALHOST_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?:[:/]|$)/i +const TUNNEL_RE = /^https?:\/\/[^/]*\.(?:ngrok\.io|ngrok-free\.app|trycloudflare\.com|loca\.lt)(?:[:/]|$)/i + +/** A human-readable reason this url looks like a dev/live-reload target, or null. */ +function devTargetReason(url: string): string | null { + if (LOCALHOST_RE.test(url)) + return 'points at localhost / 127.0.0.1' + if (RFC1918_RE.test(url)) + return 'points at a private LAN IP (live-reload)' + if (TUNNEL_RE.test(url)) + return 'points at a dev tunnel host (ngrok / cloudflare / localtunnel)' + if (/^http:\/\//i.test(url)) + return 'uses cleartext http://' + return null +} + +export const serverUrlShipped: PrescanCheck = { + id: 'ios/capacitor-server-url-shipped', + platforms: ['ios'], + appliesTo: ctx => serverUrlOf(ctx) !== null, + async run(ctx): Promise { + const url = serverUrlOf(ctx) + if (url === null) + return [] + const uploading = willUploadToAppStore(ctx) + const reason = devTargetReason(url) + const detail = reason + ? `server.url=${url} (${reason}) — this ships a live-reload/remote endpoint into the build` + : `server.url=${url} — this ships a remote endpoint into the build instead of the bundled web assets` + return [{ + id: 'ios/capacitor-server-url-shipped', + severity: uploading ? 'error' : 'warning', + title: 'capacitor.config server.url is set — the build will load a remote URL instead of bundled assets', + detail, + fix: 'Remove the server.url live-reload block before a production build; build web assets and run `npx cap sync`', + }] + }, +} + +export const serverCleartext: PrescanCheck = { + id: 'ios/capacitor-server-cleartext', + platforms: ['ios'], + appliesTo: ctx => serverOf(ctx)?.cleartext === true, + async run(ctx): Promise { + if (serverOf(ctx)?.cleartext !== true) + return [] + const url = serverUrlOf(ctx) + const httpUrl = url !== null && /^http:\/\//i.test(url) + return [{ + id: 'ios/capacitor-server-cleartext', + severity: httpUrl ? 'error' : 'warning', + title: 'capacitor.config server.cleartext is enabled — arbitrary cleartext HTTP traffic is allowed', + detail: httpUrl ? `paired with a cleartext server.url (${url})` : undefined, + fix: 'Remove server.cleartext (or set it false) for production; use https or a scoped ATS exception', + }] + }, +} + +/** A blanket `*` or public-suffix wildcard (`*.com`, `*.io`) with no specific host. */ +function isPublicWildcard(entry: string): boolean { + if (entry === '*') + return true + // `*.` such as *.com / *.io — a wildcard with no concrete host. + // `*.example.com` (two+ labels after the dot) is a specific subdomain wildcard + // and is NOT flagged. + return /^\*\.[a-z0-9-]+$/i.test(entry) +} +function navWildcards(ctx: ScanContext): string[] { + const list = serverOf(ctx)?.allowNavigation + if (!Array.isArray(list)) + return [] + return list.filter((e): e is string => typeof e === 'string' && isPublicWildcard(e)) +} + +export const allowNavigationWildcard: PrescanCheck = { + id: 'ios/capacitor-allow-navigation-wildcard', + platforms: ['ios'], + appliesTo: ctx => navWildcards(ctx).length > 0, + async run(ctx): Promise { + const offenders = navWildcards(ctx) + if (offenders.length === 0) + return [] + return [{ + id: 'ios/capacitor-allow-navigation-wildcard', + severity: 'warning', + title: 'capacitor.config server.allowNavigation contains a blanket wildcard', + detail: `wildcard entr(y/ies): ${offenders.join(', ')}`, + fix: 'Restrict allowNavigation to specific hosts; remove blanket "*" / "*." entries', + }] + }, +} diff --git a/cli/src/build/prescan/checks/ios-entitlements-checks.ts b/cli/src/build/prescan/checks/ios-entitlements-checks.ts new file mode 100644 index 0000000000..0e1e413d90 --- /dev/null +++ b/cli/src/build/prescan/checks/ios-entitlements-checks.ts @@ -0,0 +1,312 @@ +// src/build/prescan/checks/ios-entitlements-checks.ts +// +// §2.C — Entitlements / capabilities checks. These compare the app's own +// entitlements file (ios/App/App/App.entitlements) against the mapped +// provisioning profiles' entitlements, plus pure format checks for +// associated-domains and app-groups. All readers are pure and never throw; +// findings surface only capability KEY names + declared identifiers (no +// credential material — see types.ts Finding doc). +import type { ProfileEntitlements } from '../../mobileprovision-parser' +import type { Finding, PrescanCheck, ScanContext } from '../types' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { parseMobileprovisionDetailedFromBase64 } from '../../mobileprovision-parser' +import { entArray, entString, readAppEntitlements } from '../ios-entitlements' +import { plistArrayStrings } from './ios-plist-read' +import { parseProvisioningMap } from './ios-profiles' + +const hasMap = (ctx: ScanContext): boolean => parseProvisioningMap(ctx).length > 0 +const hasAppEntitlements = (ctx: ScanContext): boolean => readAppEntitlements(ctx.projectDir) !== null + +/** + * Independent evidence the app actually uses push: the Info.plist declares the + * `remote-notification` background mode. Used to distinguish a genuine + * development-vs-app_store push mismatch from the benign default Capacitor + * `aps-environment=development` leftover that nearly every push-free app carries. + * Reads only the project Info.plist; never throws. + */ +function appUsesRemoteNotifications(projectDir: string): boolean { + const p = join(projectDir, 'ios', 'App', 'App', 'Info.plist') + if (!existsSync(p)) + return false + try { + return plistArrayStrings(readFileSync(p, 'utf8'), 'UIBackgroundModes').includes('remote-notification') + } + catch { + return false + } +} + +/** Parse a mapped profile's entitlements; {} when the blob is not a valid profile. */ +function profileEntitlementsOf(base64: string): ProfileEntitlements { + try { + return parseMobileprovisionDetailedFromBase64(base64).profileEntitlements + } + catch { + return {} + } +} + +// Auto-managed keys the profile always carries / the build pipeline injects. +// aps-environment has its own check (entitlements-aps-environment-vs-mode). +const EXCLUDED_KEYS = new Set([ + 'aps-environment', + 'get-task-allow', + 'application-identifier', +]) +function isExcludedKey(key: string): boolean { + return EXCLUDED_KEYS.has(key) || key.endsWith('.team-identifier') +} + +// A profile array member that grants every requested member. Profiles store the +// wildcard either as the bare `*`, the unresolved `$(VAR)*` template form, or the +// RESOLVED 10-char-team-prefixed form `.*` (e.g. `ABCDE12345.*`) — which is +// what a signed wildcard-App-ID profile actually carries, never the $() variable. +function isWildcardMember(member: string): boolean { + return member === '*' + || member === '$(AppIdentifierPrefix)*' + || /^\$\(\w+\)\*$/.test(member) + || /^[A-Z0-9]{10}\.\*$/.test(member) +} + +// App entitlement array members carry the unresolved Xcode prefix variable +// ($(AppIdentifierPrefix) / $(TeamIdentifierPrefix)), while the profile carries the +// resolved 10-char team prefix. Strip both so the suffixes compare like-for-like. +const APP_PREFIX_VAR_RE = /^\$\((?:AppIdentifierPrefix|TeamIdentifierPrefix)\)/ +const RESOLVED_TEAM_PREFIX_RE = /^[A-Z0-9]{10}\./ +function entitlementMemberSuffix(member: string): string { + if (APP_PREFIX_VAR_RE.test(member)) + return member.replace(APP_PREFIX_VAR_RE, '') + if (RESOLVED_TEAM_PREFIX_RE.test(member)) + return member.replace(RESOLVED_TEAM_PREFIX_RE, '') + return member +} + +/** + * TOP-LEVEL entitlement keys present in the app entitlements ``, each + * tagged with the kind of its sibling value (array vs scalar). When a key's + * value is a container (`` / ``), the scan skips past that + * container's MATCHING close so keys nested inside it are NOT collected — + * otherwise a nested key would leak into the capability set and feed a false + * positive into the ERROR-severity entitlements-vs-profile-capability check. + * Self-closing empty containers (`` / ``) carry no inner keys + * and need no skip. Pure; never throws. + */ +function appEntitlementKeys(raw: string): { key: string, isArray: boolean }[] { + const out: { key: string, isArray: boolean }[] = [] + // A key element followed by its value's opening tag (or a self-closing one). + const keyRe = /([\s\S]*?)<\/key>\s*<(array|string|true|false|dict|integer|real|data|date)(\/)?\s*>/g + let m = keyRe.exec(raw) + while (m !== null) { + const key = m[1].trim() + const valueTag = m[2] + const selfClosing = m[3] === '/' + out.push({ key, isArray: valueTag === 'array' }) + // For a non-empty container value, jump the cursor past its matching close + // so the next iteration resumes at the following TOP-LEVEL sibling key. + if (!selfClosing && (valueTag === 'dict' || valueTag === 'array')) { + const skipTo = matchingClose(raw, valueTag, m.index + m[0].length) + if (skipTo > keyRe.lastIndex) + keyRe.lastIndex = skipTo + } + m = keyRe.exec(raw) + } + return out +} + +/** + * Index just past the `` that balances the container opened immediately + * before `from` (one level already open). Falls back to the input length when + * the container is never closed (malformed), which safely halts the outer scan. + */ +function matchingClose(raw: string, tag: 'dict' | 'array', from: number): number { + const tagRe = new RegExp(`<${tag}>|`, 'g') + tagRe.lastIndex = from + let depth = 1 + for (let t = tagRe.exec(raw); t !== null; t = tagRe.exec(raw)) { + if (t[0] === `<${tag}>`) + depth++ + else if (--depth === 0) + return tagRe.lastIndex + } + return raw.length +} + +export const entitlementsVsProfileCapability: PrescanCheck = { + id: 'ios/entitlements-vs-profile-capability', + platforms: ['ios'], + appliesTo: ctx => hasMap(ctx) && hasAppEntitlements(ctx), + async run(ctx): Promise { + const app = readAppEntitlements(ctx.projectDir) + if (!app) + return [] + const appKeys = appEntitlementKeys(app.raw).filter(k => !isExcludedKey(k.key)) + if (appKeys.length === 0) + return [] + + const findings: Finding[] = [] + for (const { bundleId, base64 } of parseProvisioningMap(ctx)) { + const profileEnt = profileEntitlementsOf(base64) + for (const { key, isArray } of appKeys) { + if (isArray) { + const appMembers = entArray(app.raw, key) + const profileValue = profileEnt[key] + const profileMembers = Array.isArray(profileValue) ? profileValue : [] + if (profileMembers.some(isWildcardMember)) + continue + // Compare on the prefix-normalized suffix: app members carry the + // $(AppIdentifierPrefix) variable, profile members the resolved team prefix. + const profileSuffixes = new Set(profileMembers.map(entitlementMemberSuffix)) + const uncovered = appMembers.filter(member => !profileSuffixes.has(entitlementMemberSuffix(member))) + if (uncovered.length > 0) { + findings.push({ + id: 'ios/entitlements-vs-profile-capability', + severity: 'error', + title: `Entitlement "${key}" is not fully covered by the provisioning profile for "${bundleId}"`, + detail: `uncovered ${key} value(s): ${uncovered.join(', ')}`, + fix: 'Enable the capability for this App ID in the Apple Developer portal, regenerate the profile, and re-save credentials (or remove the unused entitlement)', + }) + } + } + else if (!(key in profileEnt)) { + findings.push({ + id: 'ios/entitlements-vs-profile-capability', + severity: 'error', + title: `Entitlement "${key}" is declared by the app but not granted by the provisioning profile for "${bundleId}"`, + detail: `missing capability: ${key}`, + fix: 'Enable the capability for this App ID in the Apple Developer portal, regenerate the profile, and re-save credentials (or remove the unused entitlement)', + }) + } + } + } + return findings + }, +} + +export const apsEnvironmentVsMode: PrescanCheck = { + id: 'ios/entitlements-aps-environment-vs-mode', + platforms: ['ios'], + appliesTo: (ctx) => { + const app = readAppEntitlements(ctx.projectDir) + return app !== null && entString(app.raw, 'aps-environment') !== null && Boolean(ctx.distributionMode) + }, + async run(ctx): Promise { + const app = readAppEntitlements(ctx.projectDir) + if (!app) + return [] + const value = entString(app.raw, 'aps-environment') + if (value === null) + return [] + + const findings: Finding[] = [] + if (ctx.distributionMode === 'app_store' && value === 'development') { + // The default Capacitor App.entitlements ships aps-environment=development. + // On a push-free app this is a benign leftover the cloud builder neither + // rewrites nor fails the archive on — so it must NOT hard-block an App Store + // build. Only escalate to a blocking error when there is independent evidence + // the app genuinely uses push (a remote-notification background mode); the + // mapped-profile production mismatch is caught separately below as an error. + const usesPush = appUsesRemoteNotifications(ctx.projectDir) + findings.push({ + id: 'ios/entitlements-aps-environment-vs-mode', + severity: usesPush ? 'error' : 'warning', + title: 'aps-environment is "development" but the build distributes to the App Store / TestFlight', + detail: usesPush + ? 'The app declares the remote-notification background mode, so it needs a production push environment for App Store / TestFlight' + : 'App Store and TestFlight push needs a production environment; this is the default Capacitor leftover and is harmless unless the app uses push notifications', + fix: 'Set aps-environment=production in App.entitlements and use a production push profile (or remove aps-environment if the app does not use push)', + }) + } + else if (ctx.distributionMode === 'ad_hoc' && value === 'production') { + findings.push({ + id: 'ios/entitlements-aps-environment-vs-mode', + severity: 'warning', + title: 'aps-environment is "production" for an ad_hoc build', + detail: 'ad_hoc builds usually use the development push environment (production is valid but uncommon)', + fix: 'Use aps-environment=development for ad_hoc unless you intend production APNs', + }) + } + + // When a provisioning map is present, the profile's aps-environment must agree + // with the app's declared value. + if (hasMap(ctx)) { + for (const { bundleId, base64 } of parseProvisioningMap(ctx)) { + const profileEnt = profileEntitlementsOf(base64) + const profileValue = profileEnt['aps-environment'] + if (typeof profileValue === 'string' && profileValue !== value) { + findings.push({ + id: 'ios/entitlements-aps-environment-vs-mode', + severity: 'error', + title: `aps-environment differs between App.entitlements and the provisioning profile for "${bundleId}"`, + detail: `app: ${value} — profile: ${profileValue}`, + fix: 'Align aps-environment in App.entitlements with the push profile (or regenerate the profile)', + }) + } + } + } + return findings + }, +} + +// applinks/webcredentials/activitycontinuation/appclips:[?mode=developer|managed] +const ASSOCIATED_DOMAIN_RE = /^(?:applinks|webcredentials|activitycontinuation|appclips):[a-z0-9.-]+(?:\?mode=(?:developer|managed))?$/i +const ASSOCIATED_DOMAIN_KEY = 'com.apple.developer.associated-domains' + +export const associatedDomainsFormat: PrescanCheck = { + id: 'ios/entitlements-associated-domains-format', + platforms: ['ios'], + appliesTo: (ctx) => { + const app = readAppEntitlements(ctx.projectDir) + return app !== null && entArray(app.raw, ASSOCIATED_DOMAIN_KEY).length > 0 + }, + async run(ctx): Promise { + const app = readAppEntitlements(ctx.projectDir) + if (!app) + return [] + const bad: string[] = [] + for (const entry of entArray(app.raw, ASSOCIATED_DOMAIN_KEY)) { + // The managed-wildcard form `service:*` (e.g. applinks:*) is valid. + if (/^(?:applinks|webcredentials|activitycontinuation|appclips):\*$/i.test(entry)) + continue + if (!ASSOCIATED_DOMAIN_RE.test(entry)) + bad.push(entry) + } + if (bad.length === 0) + return [] + return [{ + id: 'ios/entitlements-associated-domains-format', + severity: 'warning', + title: `${bad.length} associated-domains entr(y/ies) have an invalid format`, + detail: `invalid: ${bad.join(', ')}`, + fix: 'Use the "service:domain" form (e.g. applinks:example.com) — no scheme, path, or trailing slash', + }] + }, +} + +const APP_GROUP_KEY = 'com.apple.security.application-groups' +// group.: lowercase letters, digits, dots, hyphens; no uppercase/whitespace. +const APP_GROUP_RE = /^group\.[a-z0-9.-]+$/ + +export const appGroupsFormat: PrescanCheck = { + id: 'ios/entitlements-app-groups-format', + platforms: ['ios'], + appliesTo: (ctx) => { + const app = readAppEntitlements(ctx.projectDir) + return app !== null && entArray(app.raw, APP_GROUP_KEY).length > 0 + }, + async run(ctx): Promise { + const app = readAppEntitlements(ctx.projectDir) + if (!app) + return [] + const bad = entArray(app.raw, APP_GROUP_KEY).filter(g => !APP_GROUP_RE.test(g)) + if (bad.length === 0) + return [] + return [{ + id: 'ios/entitlements-app-groups-format', + severity: 'warning', + title: `${bad.length} app group identifier(s) have an invalid format`, + detail: `invalid: ${bad.join(', ')}`, + fix: 'Name app groups "group." (lowercase, no whitespace) and register them in the portal', + }] + }, +} diff --git a/cli/src/build/prescan/checks/ios-plist-checks.ts b/cli/src/build/prescan/checks/ios-plist-checks.ts new file mode 100644 index 0000000000..00e486700c --- /dev/null +++ b/cli/src/build/prescan/checks/ios-plist-checks.ts @@ -0,0 +1,409 @@ +// src/build/prescan/checks/ios-plist-checks.ts +// +// §2.A Info.plist / App Store checks. All local (read only project files), all +// gated on the Capacitor Info.plist existing at ios/App/App/Info.plist. Every +// value-format check pipes the plist value through resolvePlistValue first, so a +// `$(VAR)` build-variable reference is substituted from the pbxproj (the single +// biggest false-positive guard) — and a still-unresolved `$()` is treated as +// "skip / cannot judge", never a finding. +// +// Finding.id is always the check id (findings never invent per-finding ids). +// detail/title/fix are printed and serialized to --json: these checks read only +// non-credential project files, so no credential material can leak. +import type { Finding, PrescanCheck, ScanContext } from '../types' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { readPbxproj } from '../../pbxproj-parser' +import { readBuildSetting, resolvePlistValue } from '../ios-pbxsettings' +import { willUploadToAppStore } from '../upload-intent' +import { + plistArrayStrings, + plistBool, + plistDictBlock, + plistHasKey, + plistString, +} from './ios-plist-read' + +const INFO_PLIST_REL = ['ios', 'App', 'App', 'Info.plist'] + +/** The Capacitor Info.plist path, or null when it is absent (non-standard layout). */ +function infoPlistPath(projectDir: string): string | null { + const p = join(projectDir, ...INFO_PLIST_REL) + return existsSync(p) ? p : null +} + +/** Read the Info.plist text, or null when absent / unreadable (never throws). */ +function readInfoPlist(projectDir: string): string | null { + const p = infoPlistPath(projectDir) + if (!p) + return null + try { + return readFileSync(p, 'utf8') + } + catch { + return null + } +} + +/** pbxproj text for the project, or '' when none — '' makes every $() unresolvable (=> skip). */ +function pbx(projectDir: string): string { + return readPbxproj(projectDir) ?? '' +} + +const hasInfoPlist = (ctx: ScanContext) => infoPlistPath(ctx.projectDir) !== null + +/** A clean `$(VAR)` / `${VAR}` reference that resolvePlistValue could not resolve. */ +function isUnresolvedRef(value: string): boolean { + return value.startsWith('$(') || value.startsWith('${') +} + +/** + * `ctx.config.server` is reachable through the schema's `.passthrough()` but is + * typed `unknown`, so read its dev-server signals defensively. Returns whether a + * live dev server (cleartext flag or a configured url) is still wired in. + */ +function hasDevServerConfig(ctx: ScanContext): boolean { + const server = (ctx.config as { server?: unknown } | undefined)?.server + if (server === null || typeof server !== 'object') + return false + const s = server as { url?: unknown, cleartext?: unknown } + return s.cleartext === true || (typeof s.url === 'string' && s.url.length > 0) +} + +const VERSION_RE = /^\d+(?:\.\d+){0,2}$/ +// Reverse-DNS, >=2 segments, no space/underscore/wildcard. +const BUNDLE_ID_RE = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9-]+)+$/i + +const ORIENTATIONS = [ + 'UIInterfaceOrientationPortrait', + 'UIInterfaceOrientationPortraitUpsideDown', + 'UIInterfaceOrientationLandscapeLeft', + 'UIInterfaceOrientationLandscapeRight', +] +const ORIENTATION_SET = new Set(ORIENTATIONS) + +const BACKGROUND_MODES = new Set([ + 'audio', + 'location', + 'voip', + 'fetch', + 'remote-notification', + 'processing', + 'bluetooth-central', + 'bluetooth-peripheral', + 'external-accessory', + 'newsstand-content', +]) + +export const plistBundleIdFormat: PrescanCheck = { + id: 'ios/plist-bundle-id-format', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const v = plistString(raw, 'CFBundleIdentifier') + if (v === null) { + return [{ + id: 'ios/plist-bundle-id-format', + severity: 'error', + title: 'Info.plist has no CFBundleIdentifier', + fix: 'Set a valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces, underscores, or wildcards).', + }] + } + const r = resolvePlistValue(v, pbx(ctx.projectDir)) + if (isUnresolvedRef(r)) + return [] // build-variable with no pbxproj match — cannot judge + if (!BUNDLE_ID_RE.test(r)) { + return [{ + id: 'ios/plist-bundle-id-format', + severity: 'error', + title: `Invalid bundle identifier "${r}"`, + detail: 'Bundle ids must be reverse-DNS (≥2 dot-separated segments) with no spaces, underscores, or wildcards.', + fix: 'Set a valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces, underscores, or wildcards).', + }] + } + return [] + }, +} + +export const plistVersionShortFormat: PrescanCheck = { + id: 'ios/plist-version-short-format', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const v = plistString(raw, 'CFBundleShortVersionString') + if (v === null) + return [] // presence owned by ios/infoplist-sanity (see spec §5) + const r = resolvePlistValue(v, pbx(ctx.projectDir)) + if (isUnresolvedRef(r)) + return [] + if (!VERSION_RE.test(r)) { + return [{ + id: 'ios/plist-version-short-format', + severity: 'error', + title: `CFBundleShortVersionString "${r}" is not a valid version (ITMS-90060)`, + detail: 'The marketing version must be ≤3 dot-separated integers (e.g. 1.4.2) — no letters or pre-release suffixes.', + fix: 'Set MARKETING_VERSION to ≤3 dot-separated integers (e.g. 1.4.2).', + }] + } + return [] + }, +} + +export const plistVersionBuildFormat: PrescanCheck = { + id: 'ios/plist-version-build-format', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const v = plistString(raw, 'CFBundleVersion') + if (v === null) + return [] + const r = resolvePlistValue(v, pbx(ctx.projectDir)) + if (isUnresolvedRef(r)) + return [] + if (!VERSION_RE.test(r)) { + return [{ + id: 'ios/plist-version-build-format', + severity: 'error', + title: `CFBundleVersion "${r}" is not a valid build number`, + detail: 'The build number must be numeric, ≤3 dot-separated integers (e.g. 42 or 1.4.42).', + fix: 'Set CURRENT_PROJECT_VERSION numeric, ≤3 integers (e.g. 42 or 1.4.42).', + }] + } + return [] + }, +} + +export const plistEncryptionCompliance: PrescanCheck = { + id: 'ios/plist-encryption-compliance', + platforms: ['ios'], + appliesTo: ctx => hasInfoPlist(ctx) && willUploadToAppStore(ctx), + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + if (plistHasKey(raw, 'ITSAppUsesNonExemptEncryption')) + return [] // present (either value) — do NOT assert which value is correct + return [{ + id: 'ios/plist-encryption-compliance', + severity: 'warning', + title: 'Info.plist is missing ITSAppUsesNonExemptEncryption', + detail: 'Without this key, App Store Connect shows a "Missing Compliance" prompt on every upload.', + fix: 'Add ITSAppUsesNonExemptEncryption= (correct for most Capacitor apps) to stop the per-upload Missing Compliance prompt.', + }] + }, +} + +export const plistAtsArbitraryLoads: PrescanCheck = { + id: 'ios/plist-ats-arbitrary-loads', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const dict = plistDictBlock(raw, 'NSAppTransportSecurity') + if (dict === null) + return [] + if (plistBool(dict, 'NSAllowsArbitraryLoads') !== true) + return [] + // Escalate to error only when an upload is intended AND a dev server config + // is still wired in (cleartext or a live server.url) — that combination ships + // arbitrary-loads to production. + const escalate = willUploadToAppStore(ctx) && hasDevServerConfig(ctx) + return [{ + id: 'ios/plist-ats-arbitrary-loads', + severity: escalate ? 'error' : 'warning', + title: 'NSAllowsArbitraryLoads is enabled (App Transport Security disabled)', + detail: escalate + ? 'Uploading with arbitrary loads enabled and a dev server (cleartext / server.url) still configured ships an insecure release.' + : 'NSAllowsArbitraryLoads disables App Transport Security for all hosts.', + fix: 'Remove NSAllowsArbitraryLoads (or set ); use scoped NSExceptionDomains; remove server.url/cleartext before release.', + }] + }, +} + +export const plistLaunchStoryboard: PrescanCheck = { + id: 'ios/plist-launch-storyboard', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const ok = plistHasKey(raw, 'UILaunchStoryboardName') || plistHasKey(raw, 'UILaunchScreen') + if (ok) + return [] + return [{ + id: 'ios/plist-launch-storyboard', + severity: 'error', + title: 'Info.plist declares no launch screen (ITMS-90475/90096)', + detail: 'App Store upload requires a launch storyboard or a UILaunchScreen dictionary.', + fix: 'Add UILaunchStoryboardName=LaunchScreen (Capacitor default) or a UILaunchScreen dict.', + }] + }, +} + +export const plistOrientationsMultitasking: PrescanCheck = { + id: 'ios/plist-orientations-multitasking', + platforms: ['ios'], + appliesTo(ctx) { + if (!hasInfoPlist(ctx)) + return false + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return false + // Full-screen-only iPad apps are exempt from the multitasking orientation rule. + if (plistBool(raw, 'UIRequiresFullScreen') === true) + return false + const family = readBuildSetting(pbx(ctx.projectDir), 'TARGETED_DEVICE_FAMILY') + if (family === null) + return false + // family is a comma list like "1,2"; iPad multitasking only matters when "2" is present. + return family.split(',').map(s => s.trim()).includes('2') + }, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + // Scope STRICTLY to the ~ipad array (the iPhone array is intentionally allowed + // to omit PortraitUpsideDown and would false-positive). Fall back to the + // non-suffixed array only when the ~ipad key is entirely absent. + let ipad = plistArrayStrings(raw, 'UISupportedInterfaceOrientations~ipad') + if (!plistHasKey(raw, 'UISupportedInterfaceOrientations~ipad')) + ipad = plistArrayStrings(raw, 'UISupportedInterfaceOrientations') + const present = new Set(ipad) + const missing = ORIENTATIONS.filter(o => !present.has(o)) + if (missing.length === 0) + return [] + return [{ + id: 'ios/plist-orientations-multitasking', + severity: 'warning', + title: `iPad multitasking requires all four orientations — missing ${missing.join(', ')} (ITMS-90474)`, + detail: 'An iPad-capable app that supports multitasking must declare all four interface orientations for iPad.', + fix: 'Add all four orientations to UISupportedInterfaceOrientations~ipad, or add UIRequiresFullScreen=.', + }] + }, +} + +export const plistOrientationsPresent: PrescanCheck = { + id: 'ios/plist-orientations-present', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const arr = plistArrayStrings(raw, 'UISupportedInterfaceOrientations') + if (arr.length === 0) { + return [{ + id: 'ios/plist-orientations-present', + severity: 'warning', + title: 'Info.plist declares no UISupportedInterfaceOrientations', + detail: 'Declare at least one supported interface orientation for iPhone.', + fix: 'Declare ≥1 valid UIInterfaceOrientation* value.', + }] + } + const bad = arr.find(o => !ORIENTATION_SET.has(o)) + if (bad !== undefined) { + return [{ + id: 'ios/plist-orientations-present', + severity: 'warning', + title: `Invalid interface orientation "${bad}"`, + detail: 'Each entry must be one of UIInterfaceOrientationPortrait, PortraitUpsideDown, LandscapeLeft, LandscapeRight.', + fix: 'Declare ≥1 valid UIInterfaceOrientation* value.', + }] + } + return [] + }, +} + +export const plistDisplayName: PrescanCheck = { + id: 'ios/plist-display-name', + platforms: ['ios'], + appliesTo: hasInfoPlist, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const pbxContent = pbx(ctx.projectDir) + // A resolved value is "good" only when it is a non-empty literal (not a + // still-unresolved $() reference). + const resolved = (key: string): string | null => { + const v = plistString(raw, key) + if (v === null) + return null + const r = resolvePlistValue(v, pbxContent) + if (isUnresolvedRef(r) || r.trim() === '') + return null + return r + } + if (resolved('CFBundleDisplayName') !== null || resolved('CFBundleName') !== null) + return [] + return [{ + id: 'ios/plist-display-name', + severity: 'warning', + title: 'Info.plist has no resolvable app display name', + detail: 'Neither CFBundleDisplayName nor CFBundleName resolves to a non-empty value.', + fix: 'Set CFBundleDisplayName or ensure PRODUCT_NAME resolves.', + }] + }, +} + +const LOCATION_USAGE_KEYS = [ + 'NSLocationWhenInUseUsageDescription', + 'NSLocationAlwaysAndWhenInUseUsageDescription', + 'NSLocationAlwaysUsageDescription', + 'NSLocationUsageDescription', +] + +export const plistBackgroundModesSanity: PrescanCheck = { + id: 'ios/plist-background-modes-sanity', + platforms: ['ios'], + appliesTo(ctx) { + if (!hasInfoPlist(ctx)) + return false + const raw = readInfoPlist(ctx.projectDir) + return raw !== null && plistHasKey(raw, 'UIBackgroundModes') + }, + async run(ctx): Promise { + const raw = readInfoPlist(ctx.projectDir) + if (raw === null) + return [] + const modes = plistArrayStrings(raw, 'UIBackgroundModes') + const findings: Finding[] = [] + // (a) unknown tokens — structural sanity, never a hard error. + const invalid = modes.filter(m => !BACKGROUND_MODES.has(m)) + if (invalid.length > 0) { + findings.push({ + id: 'ios/plist-background-modes-sanity', + severity: 'warning', + title: `Unknown UIBackgroundModes value(s): ${invalid.join(', ')}`, + detail: 'Background modes must be one of Apple\'s documented UIBackgroundModes tokens.', + fix: 'Remove unused background modes; add matching usage strings/capabilities.', + }) + } + // (b) location mode without a usage string — Guideline 2.5.4. Gate to upload. + if (modes.includes('location') && willUploadToAppStore(ctx)) { + const hasUsage = LOCATION_USAGE_KEYS.some(k => plistHasKey(raw, k)) + if (!hasUsage) { + findings.push({ + id: 'ios/plist-background-modes-sanity', + severity: 'warning', + title: 'UIBackgroundModes declares "location" without a location usage description (Guideline 2.5.4)', + detail: 'App Review rejects background location without an NSLocation*UsageDescription string.', + fix: 'Add the matching NSLocation*UsageDescription, or remove the location background mode.', + }) + } + } + return findings + }, +} diff --git a/cli/src/build/prescan/checks/ios-plist-read.ts b/cli/src/build/prescan/checks/ios-plist-read.ts new file mode 100644 index 0000000000..80ba8c2e9b --- /dev/null +++ b/cli/src/build/prescan/checks/ios-plist-read.ts @@ -0,0 +1,98 @@ +// src/build/prescan/checks/ios-plist-read.ts +// +// Shared, value-typed readers for iOS Info.plist / entitlements XML. Targeted +// regexes are deliberate: plist sibling order is lost in object-mode XML +// parsers, so full-tree parsing buys nothing for the shallow keys the prescan +// checks inspect, and avoids a parser dependency entirely. +// +// Every reader is pure and NEVER throws — it returns null/[]/false on a missing +// key or malformed input. Key regexes are built with escapeRegex so a key that +// contains regex metacharacters (e.g. dotted reverse-DNS keys) can never make +// the pattern invalid. + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Top-level `KV` value, or null when absent. + * V is returned verbatim, including an unresolved `$(VAR)` build-variable + * reference — callers run it through resolvePlistValue before validating. + */ +export function plistString(raw: string, key: string): string | null { + const re = new RegExp(`${escapeRegex(key)}\\s*([\\s\\S]*?)`) + return raw.match(re)?.[1] ?? null +} + +/** + * Boolean for `K\s*|`. Returns null when the key is + * absent OR maps to a non-bool value — null is meaningfully distinct from false + * (e.g. UIRequiresFullScreen absent vs. explicitly false). + */ +export function plistBool(raw: string, key: string): boolean | null { + const re = new RegExp(`${escapeRegex(key)}\\s*<(true|false)\\s*/>`) + const m = raw.match(re) + if (!m) + return null + return m[1] === 'true' +} + +/** Presence-only check for a `K` element. */ +export function plistHasKey(raw: string, key: string): boolean { + return raw.includes(`${key}`) +} + +/** + * The `` children of `K\s*...`, or [] when the + * key is absent / not an array. Non-greedy so the first `` wins. Note + * that the key match is anchored on ``, so a suffixed key such as + * `K~ipad` is NOT matched by a request for the plain `K` (and vice-versa). + */ +export function plistArrayStrings(raw: string, key: string): string[] { + const re = new RegExp(`${escapeRegex(key)}\\s*([\\s\\S]*?)`) + const block = raw.match(re)?.[1] + if (block === undefined) + return [] + const out: string[] = [] + for (const m of block.matchAll(/([\s\S]*?)<\/string>/g)) + out.push(m[1].trim()) + return out +} + +/** + * Inner text of `K\s*...`, or null when the key is + * absent. Returns the FULL balanced dict: it scans `` / `` depth + * from the opening dict to its matching close, so arbitrarily nested dicts are + * captured intact. This matters for keys like NSAppTransportSecurity, where a + * nested NSExceptionDomains dict appearing BEFORE a sibling such as + * NSAllowsArbitraryLoads would otherwise truncate the block at the first + * `` and hide the sibling. Used to scope reads such as + * NSAppTransportSecurity. Pure; returns null on a missing key or an unbalanced + * (malformed) dict. + */ +export function plistDictBlock(raw: string, key: string): string | null { + // Locate the opening `` that immediately follows the key. + const openRe = new RegExp(`${escapeRegex(key)}\\s*`) + const openMatch = raw.match(openRe) + if (openMatch?.index === undefined) + return null + const innerStart = openMatch.index + openMatch[0].length + + // Walk forward tracking nesting depth so the matching `` wins, not the + // first one. depth starts at 1 for the opening dict already consumed. + const tagRe = /|<\/dict>/g + tagRe.lastIndex = innerStart + let depth = 1 + for (let m = tagRe.exec(raw); m !== null; m = tagRe.exec(raw)) { + if (m[0] === '') { + depth++ + } + else { + depth-- + if (depth === 0) + return raw.slice(innerStart, m.index) + } + } + // Unbalanced — no matching close. Mirror the other readers' "skip" contract. + return null +} diff --git a/cli/src/build/prescan/checks/ios-plist.ts b/cli/src/build/prescan/checks/ios-plist.ts index 6c3ca62915..cb104f941c 100644 --- a/cli/src/build/prescan/checks/ios-plist.ts +++ b/cli/src/build/prescan/checks/ios-plist.ts @@ -2,17 +2,7 @@ import type { Finding, PrescanCheck } from '../types' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' - -/** - * Extract a top-level value for a key without relying on sibling ordering. - * Targeted regexes are deliberate here: plist sibling order is lost in - * fast-xml-parser's object mode, so full-tree parsing buys nothing for the - * shallow string keys this check inspects. - */ -function plistStringValue(raw: string, key: string): string | null { - const re = new RegExp(`${key}\\s*([\\s\\S]*?)`) - return raw.match(re)?.[1] ?? null -} +import { plistString } from './ios-plist-read' // RFC 3986/1738 scheme grammar - no underscores. Exported so the Android // deep-link check (manifest.ts re-exports this) validates schemes with the @@ -71,7 +61,7 @@ export const infoplistSanity: PrescanCheck = { for (const key of PURPOSE_KEYS) { if (!raw.includes(`${key}`)) continue - const value = plistStringValue(raw, key) + const value = plistString(raw, key) if (value === null || PLACEHOLDERS.has(value.trim().toLowerCase()) || value.trim().length < 8) { findings.push({ id: 'ios/infoplist-sanity', diff --git a/cli/src/build/prescan/checks/ios-pods-assets.ts b/cli/src/build/prescan/checks/ios-pods-assets.ts new file mode 100644 index 0000000000..4b44d80b8f --- /dev/null +++ b/cli/src/build/prescan/checks/ios-pods-assets.ts @@ -0,0 +1,392 @@ +// src/build/prescan/checks/ios-pods-assets.ts +// +// §2.E (Pods / SPM) + §2.F (App icons / assets) of the iOS prescan expansion. +// All checks are LOCAL (read only project files) and never throw - the parsers +// (readContentsJson / readTextIfExists / readPbxproj) all return null/[] on +// missing or malformed input, so a degraded project produces no findings rather +// than a crash. Findings surface only file paths / icon filenames / bundle-id-free +// structure, never credential material. +// +// ── Builder-grounded severity decisions ────────────────────────────────────── +// Verified against the real cloud build: +// - capgo_builder_new/.github/workflows/github-ios-build.yml (runner shell) +// - capgo_builder_new/src/fastlaneTemplateIos.ts (the injected Fastfile) +// +// What the cloud build actually does: +// 1. CocoaPods: the `install_pods` lane runs `cocoapods(repo_update: true)` +// whenever a Podfile is found - it REGENERATES `Pods/` and the `.xcworkspace`. +// => pods-not-installed and pods-capacitor-missing are NOT hard build-breakers +// server-side, so both are DOWNGRADED error -> warning. +// 2. SPM: `build_app` (gym -> xcodebuild) RESOLVES Swift Package dependencies +// during the build; a missing Package.resolved is regenerated on the runner. +// => spm-package-resolved-missing is DOWNGRADED error -> warning. The builder +// never runs `npx cap sync`, so Package.swift is not regenerated, but the +// `/capacitor-swift-pm/` + `.product(Capacitor)` regex is high-FP, so +// spm-capacitor-dependency-missing is also DOWNGRADED error -> warning. +// 3. AppIcon: `xcodebuild`/`actool` TOLERATE an empty AppIcon.appiconset for +// ad_hoc/dev exports - an empty set only fails App Store *upload* validation +// (ITMS-90704). => appicon-empty-or-placeholder is DOWNGRADED error -> warning +// and UPLOAD-GATED back to error via willUploadToAppStore. A referenced PNG +// that is MISSING from disk genuinely fails `actool` server-side, so +// appicon-referenced-file-missing KEEPS error. appicon-marketing-missing stays +// error but remains upload-gated (its appliesTo is willUploadToAppStore). + +import type { Finding, PrescanCheck, ScanContext } from '../types' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { appIconSetDir, hasMarketingIcon, readContentsJson } from '../ios-appicon' +import { readBuildSetting } from '../ios-pbxsettings' +import { readPbxproj } from '../../pbxproj-parser' +import { readTextIfExists } from '../gradle' + +// ── Layout discriminators (spec §2.E) ──────────────────────────────────────── +// CocoaPods = ios/App/Podfile present. SPM = ios/App/CapApp-SPM/Package.swift present. +function podfilePath(projectDir: string): string { + return join(projectDir, 'ios', 'App', 'Podfile') +} + +function packageSwiftPath(projectDir: string): string { + return join(projectDir, 'ios', 'App', 'CapApp-SPM', 'Package.swift') +} + +function hasPodfile(ctx: ScanContext): boolean { + return existsSync(podfilePath(ctx.projectDir)) +} + +function hasPackageSwift(ctx: ScanContext): boolean { + return existsSync(packageSwiftPath(ctx.projectDir)) +} + +function pbxContent(ctx: ScanContext): string | null { + return readPbxproj(ctx.projectDir) +} + +// willUploadToAppStore is re-implemented locally to avoid a circular import path +// concern; mirror upload-intent.ts exactly (iOS + app_store mode default + full +// ASC triplet). Kept tiny and in-sync with upload-intent.willUploadToAppStore. +function uploading(ctx: ScanContext): boolean { + if (ctx.platform !== 'ios') + return false + const mode = ctx.distributionMode ?? 'app_store' + if (mode !== 'app_store') + return false + const c = ctx.credentials + return Boolean(c?.APPLE_KEY_ID && c?.APPLE_ISSUER_ID && c?.APPLE_KEY_CONTENT) +} + +// ── §2.E Pods ───────────────────────────────────────────────────────────────── + +/** + * Podfile present but `Pods/` or `App.xcworkspace` missing. The cloud builder + * runs `pod install` (regenerating both), so this is a WARNING, not an error - + * it slows the first build and breaks local Xcode opens, but the server recovers. + */ +export const podsNotInstalled: PrescanCheck = { + id: 'ios/pods-not-installed', + platforms: ['ios'], + appliesTo: hasPodfile, + async run(ctx): Promise { + const appDir = join(ctx.projectDir, 'ios', 'App') + const podsDir = existsSync(join(appDir, 'Pods')) + const workspace = existsSync(join(appDir, 'App.xcworkspace')) + if (podsDir && workspace) + return [] + + const missing: string[] = [] + if (!podsDir) + missing.push('ios/App/Pods') + if (!workspace) + missing.push('ios/App/App.xcworkspace') + + return [{ + id: 'ios/pods-not-installed', + severity: 'warning', + title: 'CocoaPods is not installed (missing Pods/ or App.xcworkspace)', + detail: `A Podfile exists but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} missing. The cloud build runs pod install and recovers, but local Xcode builds will fail.`, + fix: 'Run `npx cap sync ios` (or `pod install`) and commit Pods/ + App.xcworkspace.', + }] + }, +} + +/** Podfile present but Podfile.lock absent - non-reproducible pod resolution. */ +export const podsLockMissing: PrescanCheck = { + id: 'ios/pods-lock-missing', + platforms: ['ios'], + appliesTo: hasPodfile, + async run(ctx): Promise { + if (readTextIfExists(join(ctx.projectDir, 'ios', 'App', 'Podfile.lock')) !== null) + return [] + return [{ + id: 'ios/pods-lock-missing', + severity: 'warning', + title: 'Podfile.lock is missing - pod versions are not pinned', + detail: 'Without Podfile.lock the cloud build resolves pod versions fresh, which can drift from your local versions.', + fix: 'Run `pod install` and commit Podfile.lock.', + }] + }, +} + +/** + * Podfile does not wire the Capacitor pod. `pod install` cannot inject a missing + * declaration, but the standard Capacitor Podfile uses the `capacitor_pods` + * helper / `require_relative` rather than a literal `pod 'Capacitor'`, so the + * literal-match heuristic is high false-positive. WARNING, not error. + */ +export const podsCapacitorMissing: PrescanCheck = { + id: 'ios/pods-capacitor-missing', + platforms: ['ios'], + appliesTo: hasPodfile, + async run(ctx): Promise { + const raw = readTextIfExists(podfilePath(ctx.projectDir)) + if (raw === null) + return [] + // Match a literal `pod 'Capacitor'` / `pod "Capacitor"` OR the standard + // capacitor_pods helper (require_relative .../@capacitor/ios or capacitor_pods). + const wiresCapacitor + = /pod\s+['"]Capacitor['"]/.test(raw) + || /capacitor_pods/.test(raw) + || /@capacitor\/ios/.test(raw) + if (wiresCapacitor) + return [] + return [{ + id: 'ios/pods-capacitor-missing', + severity: 'warning', + title: 'Podfile does not appear to wire the Capacitor pod', + detail: 'No `pod \'Capacitor\'`, `capacitor_pods` helper, or `@capacitor/ios` reference was found. If this is intentional (custom pod names) you can ignore this.', + fix: 'Run `npx cap sync ios` then `pod install`.', + }] + }, +} + +// ── §2.E SPM ──────────────────────────────────────────────────────────────── + +/** + * SPM project with no Package.resolved in either known location. `build_app` + * (xcodebuild) resolves packages during the build, so this is a WARNING - a + * committed Package.resolved just makes resolution reproducible and faster. + */ +export const spmPackageResolvedMissing: PrescanCheck = { + id: 'ios/spm-package-resolved-missing', + platforms: ['ios'], + appliesTo: hasPackageSwift, + async run(ctx): Promise { + const xcodeprojResolved = join( + ctx.projectDir, + 'ios', + 'App', + 'App.xcodeproj', + 'project.xcworkspace', + 'xcshareddata', + 'swiftpm', + 'Package.resolved', + ) + const capAppResolved = join(ctx.projectDir, 'ios', 'App', 'CapApp-SPM', 'Package.resolved') + if (existsSync(xcodeprojResolved) || existsSync(capAppResolved)) + return [] + return [{ + id: 'ios/spm-package-resolved-missing', + severity: 'warning', + title: 'Swift Package Manager Package.resolved is missing', + detail: 'No Package.resolved found under the xcodeproj or CapApp-SPM. The cloud build resolves packages during build_app, but committing Package.resolved pins versions for reproducible builds.', + fix: 'Run `xcodebuild -resolvePackageDependencies` (or open in Xcode) and commit Package.resolved.', + }] + }, +} + +/** + * Package.swift does not declare the capacitor-swift-pm dependency / the + * Capacitor product. The builder never runs `cap sync`, but Package.swift is + * CLI-managed ("DO NOT MODIFY") and the regex is high false-positive on valid + * variants, so this is a WARNING, not an error. + */ +export const spmCapacitorDependencyMissing: PrescanCheck = { + id: 'ios/spm-capacitor-dependency-missing', + platforms: ['ios'], + appliesTo: hasPackageSwift, + async run(ctx): Promise { + const raw = readTextIfExists(packageSwiftPath(ctx.projectDir)) + if (raw === null) + return [] + const hasPackage = /capacitor-swift-pm/.test(raw) + const hasProduct = /\.product\(\s*name:\s*['"]Capacitor['"]/.test(raw) + if (hasPackage && hasProduct) + return [] + return [{ + id: 'ios/spm-capacitor-dependency-missing', + severity: 'warning', + title: 'Package.swift does not declare the Capacitor SPM dependency', + detail: 'Expected a capacitor-swift-pm package dependency and a `.product(name: "Capacitor", ...)` target dependency. If your project uses a non-standard layout you can ignore this.', + fix: 'Run `npx cap sync ios` to regenerate Package.swift.', + }] + }, +} + +// ── §2.F App icons / assets ─────────────────────────────────────────────────── + +/** + * AppIcon.appiconset directory missing, Contents.json missing/malformed, or no + * icon images declared. The directory-missing and malformed-Contents cases are + * always errors (the asset catalog is structurally broken). The "no images" + * case is a WARNING by default (actool tolerates it for ad_hoc/dev) and + * ESCALATES to error when uploading to the App Store (ITMS-90704 blocks upload). + */ +export const appiconEmptyOrPlaceholder: PrescanCheck = { + id: 'ios/appicon-empty-or-placeholder', + platforms: ['ios'], + async run(ctx): Promise { + const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined) + if (!existsSync(dir)) { + return [{ + id: 'ios/appicon-empty-or-placeholder', + severity: 'error', + title: 'AppIcon.appiconset is missing', + detail: `Expected an app-icon asset set at ${dir.replace(ctx.projectDir, '.')} but the directory does not exist.`, + fix: 'Run `npx @capacitor/assets generate` (or add the icon set in Xcode).', + }] + } + + const c = readContentsJson(join(dir, 'Contents.json')) + if (c === null) { + return [{ + id: 'ios/appicon-empty-or-placeholder', + severity: 'error', + title: 'AppIcon Contents.json is missing or malformed', + detail: 'The app-icon Contents.json could not be parsed as JSON.', + fix: 'Regenerate the icon set (`npx @capacitor/assets generate`) or fix the malformed Contents.json.', + }] + } + + const hasImage = (c.images ?? []).some(i => i.filename?.trim()) + if (hasImage) + return [] + + // No icon images: warning normally, error when it would block an upload. + return [{ + id: 'ios/appicon-empty-or-placeholder', + severity: uploading(ctx) ? 'error' : 'warning', + title: 'AppIcon.appiconset declares no icon images', + detail: uploading(ctx) + ? 'The icon set has no images - App Store upload will reject it (ITMS-90704).' + : 'The icon set has no images. The build tolerates this for ad_hoc/development, but App Store upload would reject it.', + fix: 'Run `npx @capacitor/assets generate` so the set has at least the 1024x1024 icon.', + }] + }, +} + +/** + * A Contents.json image entry references a filename that is missing from disk. + * `actool` FAILS server-side in this case, so this KEEPS error severity. + */ +export const appiconReferencedFileMissing: PrescanCheck = { + id: 'ios/appicon-referenced-file-missing', + platforms: ['ios'], + appliesTo(ctx): boolean { + const dir = appIconSetDir(ctx.projectDir, readPbxproj(ctx.projectDir) ?? undefined) + return readContentsJson(join(dir, 'Contents.json')) !== null + }, + async run(ctx): Promise { + const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined) + const c = readContentsJson(join(dir, 'Contents.json')) + if (c === null) + return [] + + const missing: string[] = [] + for (const image of c.images ?? []) { + const filename = image.filename?.trim() + if (!filename) + continue + if (!existsSync(join(dir, filename))) + missing.push(filename) + } + if (missing.length === 0) + return [] + + return [{ + id: 'ios/appicon-referenced-file-missing', + severity: 'error', + title: 'AppIcon Contents.json references icon file(s) missing from disk', + detail: `actool will fail the build: missing ${missing.join(', ')}.`, + fix: 'Regenerate the icons (`npx @capacitor/assets generate`) or commit the referenced PNG(s).', + }] + }, +} + +/** + * App Store marketing icon (1024x1024 / role=marketing) missing. Upload-gated: + * ad_hoc/dev builds do not need it, but App Store upload requires it + * (ITMS-90704). KEEPS error severity. + */ +export const appiconMarketingMissing: PrescanCheck = { + id: 'ios/appicon-marketing-missing', + platforms: ['ios'], + appliesTo: uploading, + async run(ctx): Promise { + const dir = appIconSetDir(ctx.projectDir, pbxContent(ctx) ?? undefined) + const c = readContentsJson(join(dir, 'Contents.json')) + // A missing/empty set is owned by appicon-empty-or-placeholder; this check + // only fires when a set exists but lacks the marketing icon specifically. + if (c === null) + return [] + if (hasMarketingIcon(c)) + return [] + return [{ + id: 'ios/appicon-marketing-missing', + severity: 'error', + title: 'App Store marketing icon (1024x1024) is missing', + detail: 'App Store upload requires a 1024x1024 marketing icon in the AppIcon set (ITMS-90704).', + fix: 'Add a 1024x1024 PNG (no alpha) marketing icon and reference it in Contents.json.', + }] + }, +} + +// ── §2.F SPM deployment-target consistency ───────────────────────────────────── + +const SPM_MIN_RE = /\.iOS\(\.v(\d+)\)/ + +/** + * SPM project where the pbxproj IPHONEOS_DEPLOYMENT_TARGET is LOWER than the + * Package.swift platform minimum (`.iOS(.vN)`) - the dangerous direction: the + * package requires a newer floor than the app builds against. Warning only; + * skipped entirely when IPHONEOS_DEPLOYMENT_TARGET is absent (inherited/unknown). + */ +export const spmDeploymentTargetConsistency: PrescanCheck = { + id: 'ios/spm-deployment-target-consistency', + platforms: ['ios'], + appliesTo(ctx): boolean { + if (!hasPackageSwift(ctx)) + return false + const pbx = readPbxproj(ctx.projectDir) + if (pbx === null) + return false + return readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET') !== null + }, + async run(ctx): Promise { + const pbx = pbxContent(ctx) + if (pbx === null) + return [] + const raw = readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET') + if (raw === null) + return [] + const pbxTarget = Number.parseFloat(raw) + if (Number.isNaN(pbxTarget)) + return [] + + const packageSwift = readTextIfExists(packageSwiftPath(ctx.projectDir)) + if (packageSwift === null) + return [] + const m = packageSwift.match(SPM_MIN_RE) + if (!m) + return [] + const spmMin = Number.parseFloat(m[1]) + if (Number.isNaN(spmMin) || pbxTarget >= spmMin) + return [] + + return [{ + id: 'ios/spm-deployment-target-consistency', + severity: 'warning', + title: 'IPHONEOS_DEPLOYMENT_TARGET is below the Package.swift iOS minimum', + detail: `The Xcode app target builds for iOS ${pbxTarget} but Package.swift requires iOS ${spmMin} (.iOS(.v${spmMin})).`, + fix: `Raise IPHONEOS_DEPLOYMENT_TARGET to >= ${spmMin}, or run \`npx cap sync ios\`.`, + }] + }, +} diff --git a/cli/src/build/prescan/checks/ios-xcode.ts b/cli/src/build/prescan/checks/ios-xcode.ts new file mode 100644 index 0000000000..88be13d495 --- /dev/null +++ b/cli/src/build/prescan/checks/ios-xcode.ts @@ -0,0 +1,324 @@ +// src/build/prescan/checks/ios-xcode.ts +// +// §2.B "Xcode project / build settings" prescan checks. All local (read only +// the project's pbxproj). They build on the scalar build-setting readers in +// ios-pbxsettings (readBuildSetting / readBuildConfigs / readTargetConfigs), +// the signable-target walk in pbxproj-parser, the shared capacitorMajor, and +// willUploadToAppStore for upload-aware severity. +// +// FP guards baked in (see the spec §0 ground-truth facts): +// - signing-team is SUPPRESSED entirely when a provisioning map is present +// (the cloud builder injects the team + switches to managed-manual signing). +// - every "value below floor / bad value" check only flags a PRESENT scalar; +// an ABSENT key means "inherited/unknown" and is skipped (no finding), +// because build-setting inheritance / xcconfig is not resolved here. +import type { PbxTarget } from '../../pbxproj-parser' +import type { Finding, PrescanCheck, ScanContext } from '../types' +import { findSignableTargets, readPbxproj } from '../../pbxproj-parser' +import { capacitorMajor } from '../capacitor-version' +import { readBuildConfigs, readBuildSetting, readTargetConfigs } from '../ios-pbxsettings' +import { willUploadToAppStore } from '../upload-intent' +import { parseProvisioningMap } from './ios-profiles' + +const APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application' + +/** pbxproj content for the project, or null when no Xcode project is found. */ +function pbxOf(ctx: ScanContext): string | null { + return readPbxproj(ctx.projectDir) +} + +/** Application-product-type targets only (extensions/watch apps excluded). */ +function appTargets(pbxContent: string): PbxTarget[] { + return findSignableTargets(pbxContent).filter(t => t.productType === APPLICATION_PRODUCT_TYPE) +} + +// ── ios/xcode-deployment-target-capacitor ──────────────────────────────────── + +// Build-breaking deployment-target floor per Capacitor major. Mirrors the +// android-style Record + default pattern. Capacitor 5/6 require +// iOS 13, 7/8 require iOS 14; unknown/newer majors default to 14. +const CAP_IOS_DEPLOYMENT_FLOORS: Record = { 5: 13, 6: 13, 7: 14, 8: 14 } +const CAP_IOS_DEPLOYMENT_DEFAULT = 14 + +function capacitorDeploymentFloor(major: number): number { + return CAP_IOS_DEPLOYMENT_FLOORS[major] ?? CAP_IOS_DEPLOYMENT_DEFAULT +} + +/** + * Lowest PRESENT IPHONEOS_DEPLOYMENT_TARGET across the project-level config and + * each app target (Release-preferred via readBuildSetting), parsed as a float; + * null when the key is absent everywhere (inherited — cannot judge). + */ +function presentDeploymentTarget(pbxContent: string): number | null { + const raw = readBuildSetting(pbxContent, 'IPHONEOS_DEPLOYMENT_TARGET') + if (raw === null) + return null + const n = Number.parseFloat(raw) + return Number.isNaN(n) ? null : n +} + +export const deploymentTargetCapacitor: PrescanCheck = { + id: 'ios/xcode-deployment-target-capacitor', + platforms: ['ios'], + appliesTo: (ctx) => { + if (capacitorMajor(ctx.projectDir) === null) + return false + const pbx = pbxOf(ctx) + return pbx !== null && presentDeploymentTarget(pbx) !== null + }, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const major = capacitorMajor(ctx.projectDir) + const target = presentDeploymentTarget(pbx) + if (major === null || target === null) + return [] + const floor = capacitorDeploymentFloor(major) + if (target >= floor) + return [] + return [{ + id: 'ios/xcode-deployment-target-capacitor', + severity: 'error', + title: `IPHONEOS_DEPLOYMENT_TARGET ${target} is below the Capacitor ${major} floor (${floor})`, + detail: `Capacitor ${major} requires iOS deployment target >= ${floor}; the build will fail otherwise.`, + fix: `Raise IPHONEOS_DEPLOYMENT_TARGET to >= ${floor} (project + app target) and the Podfile platform line if present.`, + }] + }, +} + +// ── ios/xcode-signing-team ─────────────────────────────────────────────────── + +const hasProvisioningMap = (ctx: ScanContext): boolean => parseProvisioningMap(ctx).length > 0 + +/** + * Release-preferred (fallback Debug) lookup of a scalar within a single target's + * configs. Mirrors the Release-vs-fallback walk readBuildSetting uses, but + * scoped to one target's config set. + */ +function targetSetting(configs: { name: string, settings: Record }[], key: string): string | undefined { + const release = configs.find(c => c.name === 'Release')?.settings[key] + if (release !== undefined) + return release + const debug = configs.find(c => c.name === 'Debug')?.settings[key] + if (debug !== undefined) + return debug + for (const c of configs) { + if (c.settings[key] !== undefined) + return c.settings[key] + } + return undefined +} + +/** Signable targets that set CODE_SIGN_STYLE but have no (non-empty) DEVELOPMENT_TEAM. */ +function targetsMissingTeam(pbxContent: string): string[] { + const out: string[] = [] + for (const { target, configs } of readTargetConfigs(pbxContent)) { + const style = targetSetting(configs, 'CODE_SIGN_STYLE') + if (style === undefined) + continue // signing style inherited — nothing to judge + const team = targetSetting(configs, 'DEVELOPMENT_TEAM') + if (team === undefined || team.trim() === '') + out.push(target.name) + } + return out +} + +export const signingTeam: PrescanCheck = { + id: 'ios/xcode-signing-team', + platforms: ['ios'], + appliesTo: (ctx) => { + // Suppress entirely when a provisioning map is present: the builder injects + // the team and switches to managed-manual signing, so a locally-absent + // DEVELOPMENT_TEAM is not load-bearing. + if (hasProvisioningMap(ctx)) + return false + const pbx = pbxOf(ctx) + return pbx !== null && targetsMissingTeam(pbx).length > 0 + }, + async run(ctx): Promise { + if (hasProvisioningMap(ctx)) + return [] + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const missing = targetsMissingTeam(pbx) + if (missing.length === 0) + return [] + const uploading = willUploadToAppStore(ctx) + return [{ + id: 'ios/xcode-signing-team', + severity: uploading ? 'error' : 'warning', + title: `${missing.length} signable target(s) set a code-signing style but no DEVELOPMENT_TEAM`, + detail: `targets without a team: ${missing.join(', ')}`, + fix: 'Set DEVELOPMENT_TEAM in Xcode (Signing & Capabilities), or supply signing creds/profiles to the cloud build so it switches to managed-manual signing.', + }] + }, +} + +// ── ios/xcode-bundle-id-mismatch-across-configs ────────────────────────────── + +/** Signable targets that PRESENT >= 2 distinct PRODUCT_BUNDLE_IDENTIFIER values across configs. */ +function targetsWithBundleIdConflict(pbxContent: string): { name: string, values: string[] }[] { + const out: { name: string, values: string[] }[] = [] + for (const { target, configs } of readTargetConfigs(pbxContent)) { + const present = configs + .map(c => c.settings.PRODUCT_BUNDLE_IDENTIFIER) + .filter((v): v is string => v !== undefined) + if (present.length < 2) + continue // single-config or all-inherited — nothing to compare + const distinct = Array.from(new Set(present)) + if (distinct.length >= 2) + out.push({ name: target.name, values: distinct }) + } + return out +} + +export const bundleIdMismatchAcrossConfigs: PrescanCheck = { + id: 'ios/xcode-bundle-id-mismatch-across-configs', + platforms: ['ios'], + appliesTo: (ctx) => { + const pbx = pbxOf(ctx) + return pbx !== null && readTargetConfigs(pbx).some((t) => { + const present = t.configs.map(c => c.settings.PRODUCT_BUNDLE_IDENTIFIER).filter(v => v !== undefined) + return present.length >= 2 + }) + }, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const conflicts = targetsWithBundleIdConflict(pbx) + if (conflicts.length === 0) + return [] + return [{ + id: 'ios/xcode-bundle-id-mismatch-across-configs', + severity: 'warning', + title: `PRODUCT_BUNDLE_IDENTIFIER differs across build configs in ${conflicts.length} target(s)`, + detail: conflicts.map(c => `${c.name}: ${c.values.join(' vs ')}`).join('; '), + fix: 'Align PRODUCT_BUNDLE_IDENTIFIER across Debug/Release (or ignore if a Debug suffix is intentional).', + }] + }, +} + +// ── ios/xcode-enable-bitcode-leftover ──────────────────────────────────────── + +/** Config names (project + target) that PRESENT ENABLE_BITCODE == YES. */ +function bitcodeYesConfigs(pbxContent: string): string[] { + return readBuildConfigs(pbxContent) + .filter(c => c.settings.ENABLE_BITCODE === 'YES') + .map(c => (c.isProjectLevel ? `project ${c.name}` : c.name)) +} + +export const enableBitcodeLeftover: PrescanCheck = { + id: 'ios/xcode-enable-bitcode-leftover', + platforms: ['ios'], + appliesTo: (ctx) => { + const pbx = pbxOf(ctx) + return pbx !== null && bitcodeYesConfigs(pbx).length > 0 + }, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const configs = bitcodeYesConfigs(pbx) + if (configs.length === 0) + return [] + return [{ + id: 'ios/xcode-enable-bitcode-leftover', + severity: 'warning', + title: 'ENABLE_BITCODE = YES is set (deprecated since Xcode 14)', + detail: `present in: ${configs.join(', ')}`, + fix: 'Set ENABLE_BITCODE = NO or delete it — Xcode 14+ ignores bitcode and Apple no longer accepts it.', + }] + }, +} + +// ── ios/xcode-swift-version-sanity ─────────────────────────────────────────── + +/** Signable targets whose PRESENT SWIFT_VERSION is < 5 or not a leading number. */ +function targetsWithBadSwiftVersion(pbxContent: string): { name: string, value: string }[] { + const out: { name: string, value: string }[] = [] + for (const { target, configs } of readTargetConfigs(pbxContent)) { + const value = targetSetting(configs, 'SWIFT_VERSION') + if (value === undefined) + continue // absent — Obj-C-only target, skip + const m = value.match(/^\s*(\d+(?:\.\d+)?)/) + const major = m ? Number.parseFloat(m[1]) : Number.NaN + if (Number.isNaN(major) || major < 5) + out.push({ name: target.name, value }) + } + return out +} + +export const swiftVersionSanity: PrescanCheck = { + id: 'ios/xcode-swift-version-sanity', + platforms: ['ios'], + appliesTo: (ctx) => { + const pbx = pbxOf(ctx) + return pbx !== null && readTargetConfigs(pbx).some(t => targetSetting(t.configs, 'SWIFT_VERSION') !== undefined) + }, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const bad = targetsWithBadSwiftVersion(pbx) + if (bad.length === 0) + return [] + return [{ + id: 'ios/xcode-swift-version-sanity', + severity: 'warning', + title: `SWIFT_VERSION is below 5 (or unparseable) in ${bad.length} target(s)`, + detail: bad.map(b => `${b.name}: ${b.value}`).join('; '), + fix: 'Set SWIFT_VERSION = 5.0 (or your intended Swift >= 5) for the affected target(s).', + }] + }, +} + +// ── ios/xcode-no-app-target ────────────────────────────────────────────────── + +export const noAppTarget: PrescanCheck = { + id: 'ios/xcode-no-app-target', + platforms: ['ios'], + appliesTo: ctx => pbxOf(ctx) !== null, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + if (appTargets(pbx).length > 0) + return [] + return [{ + id: 'ios/xcode-no-app-target', + severity: 'error', + title: 'No application target found in the Xcode project', + detail: 'The pbxproj parsed but contains no com.apple.product-type.application target — the build has nothing to archive.', + fix: 'Run `npx cap sync ios`, or restore the application target in Xcode.', + }] + }, +} + +// ── ios/xcode-multiple-app-targets ─────────────────────────────────────────── + +export const multipleAppTargets: PrescanCheck = { + id: 'ios/xcode-multiple-app-targets', + platforms: ['ios'], + appliesTo: (ctx) => { + const pbx = pbxOf(ctx) + return pbx !== null && appTargets(pbx).length > 1 + }, + async run(ctx): Promise { + const pbx = pbxOf(ctx) + if (!pbx) + return [] + const apps = appTargets(pbx) + if (apps.length <= 1) + return [] + return [{ + id: 'ios/xcode-multiple-app-targets', + severity: 'warning', + title: `${apps.length} application targets found — the build may sign/archive the wrong one`, + detail: `app targets: ${apps.map(t => `${t.name} (${t.bundleId})`).join(', ')}`, + fix: 'Keep a single application target, remove the duplicate, or pass the intended scheme to the build.', + }] + }, +} diff --git a/cli/src/build/prescan/ios-appicon.ts b/cli/src/build/prescan/ios-appicon.ts new file mode 100644 index 0000000000..82251f357b --- /dev/null +++ b/cli/src/build/prescan/ios-appicon.ts @@ -0,0 +1,59 @@ +// src/build/prescan/ios-appicon.ts +// +// Reader for the AppIcon asset-catalog Contents.json. Contents.json is real +// JSON, so JSON.parse (in try/catch) is the parse-safety net — readContentsJson +// NEVER throws (null on missing/malformed). The other helpers are pure. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { readBuildSetting } from './ios-pbxsettings' + +export interface AppIconImage { + idiom?: string + size?: string + scale?: string + filename?: string + platform?: string + role?: string +} + +export interface AssetContents { + images?: AppIconImage[] + info?: { version?: number, author?: string } +} + +/** + * Parse an asset-catalog Contents.json. Returns null on a missing file OR a + * parse error — the asset catalog is the one place an invalid hand-edit can + * crash a naive reader, so this is the shared safety net for the appicon checks. + * Never throws. + */ +export function readContentsJson(path: string): AssetContents | null { + try { + return JSON.parse(readFileSync(path, 'utf8')) as AssetContents + } + catch { + return null + } +} + +/** + * Absolute path to the project's `.appiconset` directory. The icon + * name comes from the pbxproj ASSETCATALOG_COMPILER_APPICON_NAME build setting + * (Release-preferred) and defaults to `AppIcon` for standard Capacitor layouts. + */ +export function appIconSetDir(projectDir: string, pbxContent?: string): string { + const iconName = (pbxContent ? readBuildSetting(pbxContent, 'ASSETCATALOG_COMPILER_APPICON_NAME') : null) ?? 'AppIcon' + return join(projectDir, 'ios', 'App', 'App', 'Assets.xcassets', `${iconName}.appiconset`) +} + +function normalizeSize(size: string | undefined): string { + return (size ?? '').trim() +} + +/** + * Whether the icon set declares the App Store marketing icon: a `1024x1024` + * size (whitespace-trimmed) or an image whose `role` is `marketing`. + */ +export function hasMarketingIcon(c: AssetContents | null): boolean { + return (c?.images ?? []).some(i => normalizeSize(i.size) === '1024x1024' || i.role === 'marketing') +} diff --git a/cli/src/build/prescan/ios-entitlements.ts b/cli/src/build/prescan/ios-entitlements.ts new file mode 100644 index 0000000000..3d8e80b459 --- /dev/null +++ b/cli/src/build/prescan/ios-entitlements.ts @@ -0,0 +1,41 @@ +// src/build/prescan/ios-entitlements.ts +// +// Reader for the app's own entitlements file. The profile-side entitlements are +// parsed by mobileprovision-parser (MobileprovisionDetail.profileEntitlements); +// the entitlement checks compare the two. All readers are pure and never throw. +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { plistArrayStrings, plistBool, plistString } from './checks/ios-plist-read' + +/** + * Read the app entitlements plist at the Capacitor-convention path + * `ios/App/App/App.entitlements`. Returns `{ raw }` or null when the file is + * absent (or unreadable). Future enhancement: resolve the path via the pbxproj + * CODE_SIGN_ENTITLEMENTS setting; the default path is reliable for Capacitor. + */ +export function readAppEntitlements(projectDir: string): { raw: string } | null { + const path = join(projectDir, 'ios', 'App', 'App', 'App.entitlements') + if (!existsSync(path)) + return null + try { + return { raw: readFileSync(path, 'utf8') } + } + catch { + return null + } +} + +/** String entitlement value (e.g. aps-environment), or null when absent. */ +export function entString(raw: string, key: string): string | null { + return plistString(raw, key) +} + +/** Array entitlement members (e.g. application-groups), or [] when absent. */ +export function entArray(raw: string, key: string): string[] { + return plistArrayStrings(raw, key) +} + +/** Boolean entitlement (e.g. get-task-allow), or null when absent. */ +export function entBool(raw: string, key: string): boolean | null { + return plistBool(raw, key) +} diff --git a/cli/src/build/prescan/ios-pbxsettings.ts b/cli/src/build/prescan/ios-pbxsettings.ts new file mode 100644 index 0000000000..d43050144f --- /dev/null +++ b/cli/src/build/prescan/ios-pbxsettings.ts @@ -0,0 +1,187 @@ +// src/build/prescan/ios-pbxsettings.ts +// +// pbxproj scalar build-setting readers + the Info.plist `$(VAR)` resolver. +// Implemented standalone (not by mutating pbxproj-parser) so the existing +// signing pipeline stays untouched; reuses findSignableTargets/PbxTarget for the +// target shape. +// +// CRITICAL parser caveat: the block capture allows ONE level of nested braces +// (the `buildSettings = { ... }` dict), mirroring resolveBundleId. Array-valued +// settings (`LD_RUNPATH_SEARCH_PATHS = ( ... )`, `GCC_PREPROCESSOR_DEFINITIONS`) +// span parentheses/lines and are deliberately NOT captured — only single-line +// SCALAR keys are returned. Build-setting inheritance / xcconfig is not +// resolved: an ABSENT scalar means "unknown/inherited", which callers treat as +// "skip". Every function is pure and returns null/[]/{} on malformed input. + +import type { PbxTarget } from '../pbxproj-parser' +import { findSignableTargets } from '../pbxproj-parser' + +export interface BuildConfig { + name: string + settings: Record + isProjectLevel: boolean +} + +export interface TargetConfigs { + target: PbxTarget + configs: { name: string, settings: Record }[] +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Pull the inner text of a single `buildSettings = { ... }` dict (one nested + * level). The outer block's name line lives outside this, captured separately. + */ +function buildSettingsBlock(configBlock: string): string { + const m = configBlock.match(/buildSettings\s*=\s*\{([\s\S]*?)\n\s*\};/) + return m?.[1] ?? '' +} + +/** Parse single-line `KEY = "?VALUE"?;` scalar pairs; skip array/paren values. */ +function parseScalarSettings(settingsText: string): Record { + const out: Record = {} + for (const line of settingsText.split('\n')) { + const m = line.match(/^\s*([A-Z0-9_]+(?:\[[^\]]*\])?)\s*=\s*(.+?);\s*$/i) + if (!m) + continue + const key = m[1] + let value = m[2].trim() + // Skip array-valued settings — `( ... )` opens a multi-line list whose + // closing paren is not on this line; never treat it as a scalar. + if (value.startsWith('(')) + continue + // Strip a single pair of surrounding double quotes. + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) + value = value.slice(1, -1) + out[key] = value + } + return out +} + +/** Each XCBuildConfiguration block's id, name, and scalar settings dict. */ +function eachConfigBlock(pbxContent: string): { id: string, name: string, settings: Record }[] { + if (!pbxContent) + return [] + const blockRe = /(\w+)\s*\/\*[^*]*\*\/\s*=\s*\{[^{}]*isa\s*=\s*XCBuildConfiguration;(?:[^{}]*\{[^}]*\})*[^}]*\}/g + const out: { id: string, name: string, settings: Record }[] = [] + for (const m of pbxContent.matchAll(blockRe)) { + const block = m[0] + const id = m[1] + const nameMatch = block.match(/\bname\s*=\s*("[^"]*"|[^;\s]+)\s*;/) + const name = nameMatch ? nameMatch[1].replace(/^"|"$/g, '') : '' + out.push({ id, name, settings: parseScalarSettings(buildSettingsBlock(block)) }) + } + return out +} + +/** IDs of the configs referenced by the PBXProject's buildConfigurationList. */ +/** IDs of the configs referenced by the PBXProject's buildConfigurationList. */ +function projectLevelConfigIds(pbxContent: string): Set { + // The PBXProject block nests `attributes = { TargetAttributes = { ... } }`, + // so a generic non-greedy block capture would stop early. Anchor on the + // `isa = PBXProject;` marker and read the first buildConfigurationList that + // follows it (the project-level one). + const listId = pbxContent.match(/isa\s*=\s*PBXProject;[\s\S]*?buildConfigurationList\s*=\s*(\w+)/)?.[1] + if (!listId) + return new Set() + return configListIds(pbxContent, listId) +} + +/** The build-config IDs listed inside a named XCConfigurationList block. */ +function configListIds(pbxContent: string, listId: string): Set { + const listRe = new RegExp(`${escapeRegex(listId)}\\s*\\/\\*[^*]*\\*\\/\\s*=\\s*\\{[^}]*isa\\s*=\\s*XCConfigurationList;[^}]*\\}`) + const listBlock = pbxContent.match(listRe)?.[0] + if (!listBlock) + return new Set() + const section = listBlock.match(/buildConfigurations\s*=\s*\(([^)]*)\)/) + if (!section) + return new Set() + return new Set(Array.from(section[1].matchAll(/(\w+)/g), m => m[1])) +} + +/** + * Per-config build settings (Debug/Release/...) plus an isProjectLevel flag for + * the configs referenced by the PBXProject configuration list. + */ +export function readBuildConfigs(pbxContent: string): BuildConfig[] { + const projectIds = projectLevelConfigIds(pbxContent) + return eachConfigBlock(pbxContent).map(c => ({ + name: c.name, + settings: c.settings, + isProjectLevel: projectIds.has(c.id), + })) +} + +/** + * Release-preferred scalar lookup across every build config. Returns the value + * from a config named Release if one carries the key, else the first config + * that carries it (fallback), else null. SCALAR keys only. + */ +export function readBuildSetting(pbxContent: string, name: string): string | null { + let fallback: string | null = null + for (const c of eachConfigBlock(pbxContent)) { + const v = c.settings[name] + if (v === undefined) + continue + if (c.name === 'Release') + return v + if (fallback === null) + fallback = v + } + return fallback +} + +/** + * If rawValue is EXACTLY one `$(VAR)` or `${VAR}` reference, substitute it via + * readBuildSetting(VAR); otherwise return rawValue unchanged. When the variable + * has no pbxproj match the raw `$()` string is returned so callers can treat a + * still-unresolved reference as "skip / cannot judge". A value that merely + * contains a `$()` among other text is left untouched (not a clean reference). + */ +export function resolvePlistValue(rawValue: string, pbxContent: string): string { + const m = rawValue.match(/^\$[({]([A-Z0-9_]+)[)}]$/i) + if (!m) + return rawValue + return readBuildSetting(pbxContent, m[1]) ?? rawValue +} + +/** + * Per signable target, the full scalar settings dict of each of its configs. + * Reuses findSignableTargets for the target identity and re-walks the native + * target blocks to associate each target name with its configuration list. + */ +export function readTargetConfigs(pbxContent: string): TargetConfigs[] { + if (!pbxContent) + return [] + const targets = findSignableTargets(pbxContent) + if (targets.length === 0) + return [] + + // name -> configurationList id, from the PBXNativeTarget blocks. + const targetConfigListByName = new Map() + const targetRe = /\w+\s*\/\*[^*]*\*\/\s*=\s*\{[^}]*isa\s*=\s*PBXNativeTarget;[^}]*\}/g + for (const tm of pbxContent.matchAll(targetRe)) { + const block = tm[0] + const nameMatch = block.match(/\bname\s*=\s*("[^"]*"|[^;\s]+)\s*;/) + const listMatch = block.match(/buildConfigurationList\s*=\s*(\w+)/) + if (!nameMatch || !listMatch) + continue + targetConfigListByName.set(nameMatch[1].replace(/^"|"$/g, ''), listMatch[1]) + } + + const allBlocks = eachConfigBlock(pbxContent) + const blockById = new Map(allBlocks.map(b => [b.id, b])) + + return targets.map((target) => { + const listId = targetConfigListByName.get(target.name) + const ids = listId ? configListIds(pbxContent, listId) : new Set() + const configs = Array.from(ids) + .map(id => blockById.get(id)) + .filter((b): b is NonNullable => b !== undefined) + .map(b => ({ name: b.name, settings: b.settings })) + return { target, configs } + }) +} diff --git a/cli/src/build/prescan/registry.ts b/cli/src/build/prescan/registry.ts index 5b9f974325..0f3f96c964 100644 --- a/cli/src/build/prescan/registry.ts +++ b/cli/src/build/prescan/registry.ts @@ -37,6 +37,49 @@ import { credentialsSaved } from './checks/credentials' import { ascKeyValid, p12Expiry, p12Opens } from './checks/ios-certs' import { infoplistSanity } from './checks/ios-plist' import { certProfilePairing, profileBundleMatch, profileExpiry, profileTypeVsMode, targetsCovered } from './checks/ios-profiles' +import { + allowNavigationWildcard, + serverCleartext, + serverUrlShipped, +} from './checks/ios-capacitor-config' +import { + appGroupsFormat, + apsEnvironmentVsMode, + associatedDomainsFormat, + entitlementsVsProfileCapability, +} from './checks/ios-entitlements-checks' +import { + plistAtsArbitraryLoads, + plistBackgroundModesSanity, + plistBundleIdFormat, + plistDisplayName, + plistEncryptionCompliance, + plistLaunchStoryboard, + plistOrientationsMultitasking, + plistOrientationsPresent, + plistVersionBuildFormat, + plistVersionShortFormat, +} from './checks/ios-plist-checks' +import { + appiconEmptyOrPlaceholder, + appiconMarketingMissing, + appiconReferencedFileMissing, + podsCapacitorMissing, + podsLockMissing, + podsNotInstalled, + spmCapacitorDependencyMissing, + spmDeploymentTargetConsistency, + spmPackageResolvedMissing, +} from './checks/ios-pods-assets' +import { + bundleIdMismatchAcrossConfigs, + deploymentTargetCapacitor, + enableBitcodeLeftover, + multipleAppTargets, + noAppTarget, + signingTeam, + swiftVersionSanity, +} from './checks/ios-xcode' import { bundleIdConsistency, capSyncStale, nodeLinkerLayout } from './checks/shared' import { apikeyPermission, appExists } from './checks/shared-remote' import { ascKeyAccess, playSaAccess } from './checks/store-access' @@ -63,4 +106,22 @@ export const ALL_CHECKS: PrescanCheck[] = [ sdkFloors, targetSdkPlay, minSdkCapacitor, versionFields, // store-access (remote) playSaAccess, ascKeyAccess, + // ios plist (Info.plist / App Store) + plistBundleIdFormat, plistVersionShortFormat, plistVersionBuildFormat, + plistEncryptionCompliance, plistAtsArbitraryLoads, plistLaunchStoryboard, + plistOrientationsMultitasking, plistOrientationsPresent, plistDisplayName, + plistBackgroundModesSanity, + // ios xcode (project / build settings) + deploymentTargetCapacitor, signingTeam, bundleIdMismatchAcrossConfigs, + enableBitcodeLeftover, swiftVersionSanity, noAppTarget, + multipleAppTargets, + // ios entitlements / capabilities + entitlementsVsProfileCapability, apsEnvironmentVsMode, + associatedDomainsFormat, appGroupsFormat, + // ios capacitor config + serverUrlShipped, serverCleartext, allowNavigationWildcard, + // ios pods / spm / app icons + podsNotInstalled, podsLockMissing, podsCapacitorMissing, + spmPackageResolvedMissing, spmCapacitorDependencyMissing, appiconEmptyOrPlaceholder, + appiconReferencedFileMissing, appiconMarketingMissing, spmDeploymentTargetConsistency, ] diff --git a/cli/test/prescan/capacitor-version.test.ts b/cli/test/prescan/capacitor-version.test.ts new file mode 100644 index 0000000000..e0f3999768 --- /dev/null +++ b/cli/test/prescan/capacitor-version.test.ts @@ -0,0 +1,68 @@ +// test/prescan/capacitor-version.test.ts +import { describe, expect, it } from 'bun:test' +import { capacitorMajor } from '../../src/build/prescan/capacitor-version' +import { makeProject } from './helpers' + +function pkg(deps: Record, devDeps: Record = {}) { + return JSON.stringify({ dependencies: deps, devDependencies: devDeps }) +} + +describe('capacitor-version: capacitorMajor', () => { + it('reads the major from @capacitor/core', () => { + const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '^8.3.1' }) }) + expect(capacitorMajor(dir)).toBe(8) + }) + + it('falls back to @capacitor/ios when core is absent', () => { + const dir = makeProject({ 'package.json': pkg({ '@capacitor/ios': '~7.2.0' }) }) + expect(capacitorMajor(dir)).toBe(7) + }) + + it('falls back to @capacitor/android when core and ios are absent', () => { + const dir = makeProject({ 'package.json': pkg({ '@capacitor/android': '6.1.2' }) }) + expect(capacitorMajor(dir)).toBe(6) + }) + + it('prefers @capacitor/core over ios/android', () => { + const dir = makeProject({ 'package.json': pkg({ + '@capacitor/core': '8.0.0', + '@capacitor/ios': '7.0.0', + '@capacitor/android': '6.0.0', + }) }) + expect(capacitorMajor(dir)).toBe(8) + }) + + it('reads from devDependencies too', () => { + const dir = makeProject({ 'package.json': pkg({}, { '@capacitor/core': '^8.3.1' }) }) + expect(capacitorMajor(dir)).toBe(8) + }) + + it('returns null when package.json is absent', () => { + const dir = makeProject({}) + expect(capacitorMajor(dir)).toBeNull() + }) + + it('returns null when package.json is malformed (never throws)', () => { + const dir = makeProject({ 'package.json': '{ not json' }) + expect(capacitorMajor(dir)).toBeNull() + }) + + it('returns null when no capacitor dependency is present', () => { + const dir = makeProject({ 'package.json': pkg({ react: '^19.0.0' }) }) + expect(capacitorMajor(dir)).toBeNull() + }) + + it('parses the leading integer from a complex range', () => { + const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '>=8.3.1 <9.0.0' }) }) + expect(capacitorMajor(dir)).toBe(8) + }) + + it('grounds clean against a real-shaped Capacitor-8 project (inline fixture)', () => { + // Self-contained inline fixture mirroring the real tutorial-app package.json + // Capacitor-8 dependency, so the grounding is REAL on CI (the external + // tutorial-app checkout does not exist there; reading it returned null and + // failed this assertion, not a meaningful grounding). + const dir = makeProject({ 'package.json': pkg({ '@capacitor/core': '^8.0.0', '@capacitor/ios': '^8.0.0' }) }) + expect(capacitorMajor(dir)).toBe(8) + }) +}) diff --git a/cli/test/prescan/checks-ios-entitlements-config.test.ts b/cli/test/prescan/checks-ios-entitlements-config.test.ts new file mode 100644 index 0000000000..7b48ccd18d --- /dev/null +++ b/cli/test/prescan/checks-ios-entitlements-config.test.ts @@ -0,0 +1,467 @@ +// test/prescan/checks-ios-entitlements-config.test.ts +import type { ScanContext } from '../../src/build/prescan/types' +import { Buffer } from 'node:buffer' +import { describe, expect, it } from 'bun:test' +import { + allowNavigationWildcard, + serverCleartext, + serverUrlShipped, +} from '../../src/build/prescan/checks/ios-capacitor-config' +import { + appGroupsFormat, + apsEnvironmentVsMode, + associatedDomainsFormat, + entitlementsVsProfileCapability, +} from '../../src/build/prescan/checks/ios-entitlements-checks' +import { makeCtx, makeProject } from './helpers' + +const b64 = (s: string) => Buffer.from(s).toString('base64') + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const PLIST_HEAD = ` + +` + +/** App.entitlements file body (inside ...). */ +function entitlementsFile(body: string): string { + return `${PLIST_HEAD}\n${body}\n` +} + +/** The exact grounding-project entitlements (aps-environment=development only). */ +const GROUNDING_ENTITLEMENTS = entitlementsFile(` + aps-environmentdevelopment`) + +/** + * Provisioning-profile XML the mobileprovision parser accepts, with an + * Entitlements dict whose body the caller supplies. Mirrors the structure the + * parser scans (, application-identifier, Entitlements dict). + */ +function profileXml(entitlementsBody: string, bundleId = 'com.demo.app', teamId = 'TEAM123456'): string { + return `${PLIST_HEAD} +NameTest Profile +UUID11111111-2222-3333-4444-555555555555 +TeamIdentifier${teamId} +ExpirationDate2099-01-01T00:00:00Z +Entitlements + application-identifier${teamId}.${bundleId} + ${entitlementsBody} + +DeveloperCertificates +` +} + +/** Serialized CAPGO_IOS_PROVISIONING_MAP: { [bundleId]: { profile, name } }. */ +function mapWith(xml: string, bundleId = 'com.demo.app'): string { + return JSON.stringify({ [bundleId]: { profile: b64(xml), name: 'Test Profile' } }) +} + +/** ScanContext with an App.entitlements file written to a temp project dir. */ +function ctxWithEntitlements(entitlementsBody: string, extra: Partial = {}): ScanContext { + const dir = makeProject({ 'ios/App/App/App.entitlements': entitlementsFile(entitlementsBody) }) + return makeCtx({ projectDir: dir, platform: 'ios', ...extra }) +} + +function ctxWithEntitlementsRaw(raw: string, extra: Partial = {}): ScanContext { + const dir = makeProject({ 'ios/App/App/App.entitlements': raw }) + return makeCtx({ projectDir: dir, platform: 'ios', ...extra }) +} + +// ASC triplet that makes willUploadToAppStore(ctx) true. +const UPLOAD_CREDS = { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } + +// =========================================================================== +// §2.C ios/entitlements-vs-profile-capability +// =========================================================================== + +describe('ios/entitlements-vs-profile-capability', () => { + it('does not apply without a provisioning map', () => { + const ctx = ctxWithEntitlements('aps-environmentdevelopment') + expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false) + }) + + it('does not apply when App.entitlements is absent', () => { + const ctx = makeCtx({ + projectDir: makeProject({}), + platform: 'ios', + credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) }, + }) + expect(entitlementsVsProfileCapability.appliesTo?.(ctx)).toBe(false) + }) + + it('errors when the app declares a capability the profile does not grant', async () => { + const ctx = ctxWithEntitlements( + 'com.apple.developer.healthkit', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } }, + ) + const f = await entitlementsVsProfileCapability.run(ctx) + expect(f[0]?.severity).toBe('error') + expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.developer.healthkit') + }) + + it('passes when the profile grants the same bool/string capability', async () => { + const ctx = ctxWithEntitlements( + 'com.apple.developer.healthkit', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.healthkit')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('errors when an app app-group member is not covered by the profile list', async () => { + const ctx = ctxWithEntitlements( + 'com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.extra', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.security.application-groupsgroup.com.demo.app')) } }, + ) + const f = await entitlementsVsProfileCapability.run(ctx) + expect(f[0]?.severity).toBe('error') + expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.security.application-groups') + }) + + it('passes when every app app-group member is in the profile list', async () => { + const ctx = ctxWithEntitlements( + 'com.apple.security.application-groupsgroup.com.demo.app', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.extra')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('treats a profile wildcard member (*) as covering all app members', async () => { + const ctx = ctxWithEntitlements( + 'keychain-access-groupsTEAM123456.com.demo.app', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groups*')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('treats a $(AppIdentifierPrefix)* profile member as covering all app members', async () => { + const ctx = ctxWithEntitlements( + 'keychain-access-groupsTEAM123456.com.demo.app', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groups$(AppIdentifierPrefix)*')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('treats a resolved-team wildcard (TEAMID.*) profile member as covering all app members', async () => { + // Real profiles store the RESOLVED 10-char team prefix, e.g. `TEAM123456.*`, + // never the $(AppIdentifierPrefix) build variable. This must be recognized as a + // wildcard or it false-positives on every keychain-sharing app with a wildcard App ID. + const ctx = ctxWithEntitlements( + 'keychain-access-groups$(AppIdentifierPrefix)app.capgo.plugin.TutorialBuild', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groupsTEAM123456.*')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('matches a $(AppIdentifierPrefix)-prefixed app member against the profile resolved-team grant', async () => { + // App entitlements carry the unresolved $(AppIdentifierPrefix) prefix; the profile + // carries the resolved . prefix. A literal (non-wildcard) grant of the same + // suffix must be treated as covered. + const ctx = ctxWithEntitlements( + 'keychain-access-groups$(AppIdentifierPrefix)app.capgo.plugin.TutorialBuild', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('keychain-access-groupsTEAM123456.app.capgo.plugin.TutorialBuild')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('passes when the profile grants a non-allowlisted scalar/bool capability (App Attest, Siri)', async () => { + // Capability keys outside the legacy profile-parser allowlist must NOT be reported + // as ungranted when the profile actually grants them. + const ctx = ctxWithEntitlements( + `com.apple.developer.devicecheck.appattest-environmentproduction + com.apple.developer.siri`, + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml(`com.apple.developer.devicecheck.appattest-environmentproduction + com.apple.developer.siri`)) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('passes when the profile grants a non-allowlisted array capability (Sign in with Apple)', async () => { + const ctx = ctxWithEntitlements( + 'com.apple.developer.applesigninDefault', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.applesigninDefault')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('still errors when a non-allowlisted capability is declared but NOT granted', async () => { + // The downgrade must not silence genuine missing-capability errors. + const ctx = ctxWithEntitlements( + 'com.apple.developer.siri', + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } }, + ) + const f = await entitlementsVsProfileCapability.run(ctx) + expect(f[0]?.severity).toBe('error') + expect(f[0]?.detail ?? f[0]?.title).toContain('com.apple.developer.siri') + }) + + it('does NOT treat keys nested inside a dict-valued entitlement as capabilities', async () => { + // Regression: appEntitlementKeys must collect only TOP-LEVEL keys. The inner + // keys of a dict-valued entitlement must NOT leak into the capability set and + // get checked against the profile — that would be a false-positive blocking + // error. The profile grants only an UNRELATED capability, so the only thing + // that could surface the nested key names is the leak this fix prevents. + const ctx = ctxWithEntitlements( + `com.apple.developer.networking.HotspotConfiguration` + + `com.apple.private.nested.flag` + + `com.apple.private.nested.listx` + + ``, + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('com.apple.developer.healthkit')) } }, + ) + const f = await entitlementsVsProfileCapability.run(ctx) + const text = f.map(x => `${x.title} ${x.detail ?? ''}`).join(' ') + // The nested keys must NEVER appear as their own capability findings. + expect(text).not.toContain('com.apple.private.nested.flag') + expect(text).not.toContain('com.apple.private.nested.list') + }) + + it('excludes auto-managed keys (aps-environment, get-task-allow, application-identifier, team-identifier)', async () => { + // App declares only auto-managed keys; profile grants none of them -> no finding. + const ctx = ctxWithEntitlements( + `aps-environmentdevelopment + get-task-allow + application-identifierTEAM123456.com.demo.app + com.apple.developer.team-identifierTEAM123456`, + { credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('')) } }, + ) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) + + it('grounding entitlements (aps-environment only) scan clean against a matching profile', async () => { + const ctx = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS, { + credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentdevelopment')) }, + }) + expect(await entitlementsVsProfileCapability.run(ctx)).toEqual([]) + }) +}) + +// =========================================================================== +// §2.C ios/entitlements-aps-environment-vs-mode +// =========================================================================== + +describe('ios/entitlements-aps-environment-vs-mode', () => { + it('does not apply without aps-environment in App.entitlements', () => { + const ctx = ctxWithEntitlements('com.apple.developer.healthkit', { distributionMode: 'app_store' }) + expect(apsEnvironmentVsMode.appliesTo?.(ctx)).toBe(false) + }) + + it('does not apply without a distributionMode', () => { + const ctx = ctxWithEntitlements('aps-environmentdevelopment') + expect(apsEnvironmentVsMode.appliesTo?.(ctx)).toBe(false) + }) + + it('warns (does NOT error) when aps-environment=development but mode is app_store with no push evidence', async () => { + // The default Capacitor App.entitlements ships aps-environment=development. On a + // push-free app this is a benign leftover the builder neither rewrites nor fails + // on, so it must NOT hard-block an App Store build — it is a warning, not an error. + const ctx = ctxWithEntitlements('aps-environmentdevelopment', { distributionMode: 'app_store' }) + const f = await apsEnvironmentVsMode.run(ctx) + expect(f[0]?.severity).toBe('warning') + expect(f.some(x => x.severity === 'error')).toBe(false) + }) + + it('errors on development+app_store when a mapped profile grants production push (real mismatch)', async () => { + // A mapped profile granting aps-environment=production is independent evidence the + // app genuinely uses push, so a development app entitlement IS a real signing mismatch. + const ctx = ctxWithEntitlements('aps-environmentdevelopment', { + distributionMode: 'app_store', + credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentproduction')) }, + }) + const f = await apsEnvironmentVsMode.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + + it('errors on development+app_store when UIBackgroundModes declares remote-notification', async () => { + // remote-notification background mode is independent evidence the app uses push. + const dir = makeProject({ + 'ios/App/App/App.entitlements': entitlementsFile('aps-environmentdevelopment'), + 'ios/App/App/Info.plist': `${PLIST_HEAD}\nUIBackgroundModesremote-notification\n`, + }) + const ctx = makeCtx({ projectDir: dir, platform: 'ios', distributionMode: 'app_store' }) + const f = await apsEnvironmentVsMode.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + + it('warns when aps-environment=production but mode is ad_hoc', async () => { + const ctx = ctxWithEntitlements('aps-environmentproduction', { distributionMode: 'ad_hoc' }) + const f = await apsEnvironmentVsMode.run(ctx) + expect(f[0]?.severity).toBe('warning') + }) + + it('passes when aps-environment=production and mode is app_store', async () => { + const ctx = ctxWithEntitlements('aps-environmentproduction', { distributionMode: 'app_store' }) + expect(await apsEnvironmentVsMode.run(ctx)).toEqual([]) + }) + + it('errors when the app aps-environment differs from the mapped profile aps-environment', async () => { + const ctx = ctxWithEntitlements('aps-environmentproduction', { + distributionMode: 'app_store', + credentials: { CAPGO_IOS_PROVISIONING_MAP: mapWith(profileXml('aps-environmentdevelopment')) }, + }) + const f = await apsEnvironmentVsMode.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + + it('grounding (development, push-free) warns under app_store — never hard-blocks the default Capacitor entitlement', async () => { + // Restores the spec acceptance baseline: the grounding project (aps-environment= + // development, no push) must NOT produce a build-blocking error on the default + // app_store path. It surfaces a warning so the leftover is still visible. + const noMode = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS) + expect(apsEnvironmentVsMode.appliesTo?.(noMode)).toBe(false) + const withMode = ctxWithEntitlementsRaw(GROUNDING_ENTITLEMENTS, { distributionMode: 'app_store' }) + const f = await apsEnvironmentVsMode.run(withMode) + expect(f[0]?.severity).toBe('warning') + expect(f.some(x => x.severity === 'error')).toBe(false) + }) +}) + +// =========================================================================== +// §2.C ios/entitlements-associated-domains-format +// =========================================================================== + +describe('ios/entitlements-associated-domains-format', () => { + it('does not apply without associated-domains', () => { + const ctx = ctxWithEntitlements('aps-environmentdevelopment') + expect(associatedDomainsFormat.appliesTo?.(ctx)).toBe(false) + }) + + it('warns on an entry with a scheme/url (applinks:https://...)', async () => { + const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:https://example.com') + const f = await associatedDomainsFormat.run(ctx) + expect(f[0]?.severity).toBe('warning') + }) + + it('warns on an entry with a trailing path', async () => { + const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:example.com/path') + expect((await associatedDomainsFormat.run(ctx))[0]?.severity).toBe('warning') + }) + + it('warns on an unknown service prefix', async () => { + const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsbogus:example.com') + expect((await associatedDomainsFormat.run(ctx))[0]?.severity).toBe('warning') + }) + + it('passes valid applinks/webcredentials entries', async () => { + const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:example.comwebcredentials:example.comapplinks:example.com?mode=developer') + expect(await associatedDomainsFormat.run(ctx)).toEqual([]) + }) + + it('does not flag the service:* managed wildcard form', async () => { + const ctx = ctxWithEntitlements('com.apple.developer.associated-domainsapplinks:*') + expect(await associatedDomainsFormat.run(ctx)).toEqual([]) + }) +}) + +// =========================================================================== +// §2.C ios/entitlements-app-groups-format +// =========================================================================== + +describe('ios/entitlements-app-groups-format', () => { + it('does not apply without application-groups', () => { + const ctx = ctxWithEntitlements('aps-environmentdevelopment') + expect(appGroupsFormat.appliesTo?.(ctx)).toBe(false) + }) + + it('warns when an entry does not start with group.', async () => { + const ctx = ctxWithEntitlements('com.apple.security.application-groupscom.demo.app') + expect((await appGroupsFormat.run(ctx))[0]?.severity).toBe('warning') + }) + + it('warns on uppercase / whitespace in the group id', async () => { + const ctx = ctxWithEntitlements('com.apple.security.application-groupsgroup.Com.Demo.App') + expect((await appGroupsFormat.run(ctx))[0]?.severity).toBe('warning') + }) + + it('passes a well-formed group identifier', async () => { + const ctx = ctxWithEntitlements('com.apple.security.application-groupsgroup.com.demo.app') + expect(await appGroupsFormat.run(ctx)).toEqual([]) + }) +}) + +// =========================================================================== +// §2.D ios/capacitor-server-url-shipped +// =========================================================================== + +function ctxConfig(server: Record | undefined, extra: Partial = {}): ScanContext { + const config = { appId: 'com.demo.app', appName: 'demo', webDir: 'dist', ...(server ? { server } : {}) } as ScanContext['config'] + return makeCtx({ projectDir: '/tmp', platform: 'ios', config, ...extra }) +} + +describe('ios/capacitor-server-url-shipped', () => { + it('does not apply when server.url is absent or empty', () => { + expect(serverUrlShipped.appliesTo?.(ctxConfig(undefined))).toBe(false) + expect(serverUrlShipped.appliesTo?.(ctxConfig({ url: '' }))).toBe(false) + }) + + it('warns when a server.url is set without upload creds', async () => { + const f = await serverUrlShipped.run(ctxConfig({ url: 'http://192.168.1.5:3000' })) + expect(f[0]?.severity).toBe('warning') + }) + + it('errors when uploading to the App Store with a server.url set', async () => { + const ctx = ctxConfig({ url: 'http://192.168.1.5:3000' }, { credentials: UPLOAD_CREDS }) + const f = await serverUrlShipped.run(ctx) + expect(f[0]?.severity).toBe('error') + }) + + it('detail flags a dev target (RFC1918 / localhost / tunnel host)', async () => { + const tunnel = await serverUrlShipped.run(ctxConfig({ url: 'https://abcd.ngrok.io' })) + expect(tunnel[0]?.detail).toBeTruthy() + const local = await serverUrlShipped.run(ctxConfig({ url: 'http://localhost:3000' })) + expect(local[0]?.detail).toBeTruthy() + }) + + it('grounding config (no server) scans clean', async () => { + expect(await serverUrlShipped.run(ctxConfig(undefined))).toEqual([]) + }) +}) + +// =========================================================================== +// §2.D ios/capacitor-server-cleartext +// =========================================================================== + +describe('ios/capacitor-server-cleartext', () => { + it('does not apply when cleartext is not true', () => { + expect(serverCleartext.appliesTo?.(ctxConfig({ cleartext: false }))).toBe(false) + expect(serverCleartext.appliesTo?.(ctxConfig(undefined))).toBe(false) + }) + + it('warns when cleartext is true', async () => { + expect((await serverCleartext.run(ctxConfig({ cleartext: true })))[0]?.severity).toBe('warning') + }) + + it('escalates to error when a http:// server.url is also present', async () => { + expect((await serverCleartext.run(ctxConfig({ cleartext: true, url: 'http://example.com' })))[0]?.severity).toBe('error') + }) +}) + +// =========================================================================== +// §2.D ios/capacitor-allow-navigation-wildcard +// =========================================================================== + +describe('ios/capacitor-allow-navigation-wildcard', () => { + it('does not apply when allowNavigation is not an array', () => { + expect(allowNavigationWildcard.appliesTo?.(ctxConfig(undefined))).toBe(false) + expect(allowNavigationWildcard.appliesTo?.(ctxConfig({ allowNavigation: 'nope' }))).toBe(false) + }) + + it('does not apply when there are no wildcard-only entries', () => { + expect(allowNavigationWildcard.appliesTo?.(ctxConfig({ allowNavigation: ['*.example.com', 'api.example.com'] }))).toBe(false) + }) + + it('warns on a blanket * entry', async () => { + const f = await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*'] })) + expect(f[0]?.severity).toBe('warning') + }) + + it('warns on a public-suffix wildcard (*.com)', async () => { + const f = await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*.com'] })) + expect(f[0]?.severity).toBe('warning') + expect(f[0]?.detail ?? f[0]?.title).toContain('*.com') + }) + + it('does not flag a specific subdomain wildcard (*.example.com)', async () => { + expect(await allowNavigationWildcard.run(ctxConfig({ allowNavigation: ['*.example.com'] }))).toEqual([]) + }) +}) diff --git a/cli/test/prescan/checks-ios-plist-checks.test.ts b/cli/test/prescan/checks-ios-plist-checks.test.ts new file mode 100644 index 0000000000..349d47aa37 --- /dev/null +++ b/cli/test/prescan/checks-ios-plist-checks.test.ts @@ -0,0 +1,358 @@ +// test/prescan/checks-ios-plist-checks.test.ts +// +// §2.A Info.plist / App Store checks. The primary regression fixture is the real +// Capacitor-8 SPM tutorial project, which MUST scan clean (zero findings) across +// every check. Per-check failing fixtures are minimal mutations of a healthy +// in-memory plist; the unresolved-$() fixture proves the resolvePlistValue guard. +import { describe, expect, it } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + plistAtsArbitraryLoads, + plistBackgroundModesSanity, + plistBundleIdFormat, + plistDisplayName, + plistEncryptionCompliance, + plistLaunchStoryboard, + plistOrientationsMultitasking, + plistOrientationsPresent, + plistVersionBuildFormat, + plistVersionShortFormat, +} from '../../src/build/prescan/checks/ios-plist-checks' +import { makeCtx, makeProject } from './helpers' + +const plist = (body: string) => ` + +${body}` + +// A pbxproj whose Release config resolves every $(VAR) the Capacitor Info.plist +// references to a healthy literal. +const PBX = ` + AAAA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; + MARKETING_VERSION = 1.0; + CURRENT_PROJECT_VERSION = 1; + PRODUCT_NAME = "Tutorial Build example app"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +` + +// Minimal healthy plist body (build-variable refs, like a real Capacitor app). +const HEALTHY_BODY = `CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) +CFBundleShortVersionString$(MARKETING_VERSION) +CFBundleVersion$(CURRENT_PROJECT_VERSION) +CFBundleDisplayNameTutorial Build example app +CFBundleName$(PRODUCT_NAME) +LSRequiresIPhoneOS +UILaunchStoryboardNameLaunchScreen +UISupportedInterfaceOrientations +UIInterfaceOrientationPortrait +UIInterfaceOrientationLandscapeLeft +UIInterfaceOrientationLandscapeRight + +UISupportedInterfaceOrientations~ipad +UIInterfaceOrientationPortrait +UIInterfaceOrientationPortraitUpsideDown +UIInterfaceOrientationLandscapeLeft +UIInterfaceOrientationLandscapeRight +` + +/** Build a ctx whose Info.plist + pbxproj are written at the Capacitor paths. */ +function ctxFor(plistBody: string, opts: { pbx?: string, partial?: Parameters[0] extends never ? never : Record } = {}) { + const files: Record = { 'ios/App/App/Info.plist': plist(plistBody) } + files['ios/App/App.xcodeproj/project.pbxproj'] = opts.pbx ?? PBX + const dir = makeProject(files) + return makeCtx({ projectDir: dir, platform: 'ios', ...(opts.partial as object) }) +} + +const ALL_CHECKS = [ + plistBundleIdFormat, + plistVersionShortFormat, + plistVersionBuildFormat, + plistEncryptionCompliance, + plistAtsArbitraryLoads, + plistLaunchStoryboard, + plistOrientationsMultitasking, + plistOrientationsPresent, + plistDisplayName, + plistBackgroundModesSanity, +] + +// Run a check honoring its appliesTo gate (returns [] when the gate is false), +// mirroring how the engine applies appliesTo before run(). +async function runGated(check: typeof ALL_CHECKS[number], ctx: ReturnType) { + if (check.appliesTo && !check.appliesTo(ctx)) + return [] + return check.run(ctx) +} + +describe('§2.A regression baseline — real-shaped Capacitor-8 project scans clean', () => { + // Self-contained inline fixture: HEALTHY_BODY + PBX mirror the real Capacitor-8 + // SPM tutorial project's Info.plist + pbxproj shape ($()-ref'd bundle id / + // versions resolved by the Release config, all four ~ipad orientations, literal + // display name). Written to a temp project dir so the grounding assertions are + // REAL on CI — the previous external absolute path made every check early-return + // [] vacuously (the plist reader returns null on a missing file). + const groundedCtx = ctxFor(HEALTHY_BODY) + + for (const check of ALL_CHECKS) { + it(`${check.id} produces no finding`, async () => { + expect(await runGated(check, groundedCtx)).toEqual([]) + }) + } + + it('sanity: the inline fixture files are written and readable', () => { + const infoPlist = join(groundedCtx.projectDir, 'ios', 'App', 'App', 'Info.plist') + const pbxproj = join(groundedCtx.projectDir, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj') + expect(readFileSync(infoPlist, 'utf8')).toContain('CFBundleIdentifier') + expect(readFileSync(pbxproj, 'utf8')).toContain('PRODUCT_BUNDLE_IDENTIFIER') + expect(groundedCtx.platform).toBe('ios') + }) +}) + +describe('synthetic healthy plist scans clean', () => { + for (const check of ALL_CHECKS) { + it(`${check.id} produces no finding on the healthy in-memory plist`, async () => { + expect(await runGated(check, ctxFor(HEALTHY_BODY))).toEqual([]) + }) + } +}) + +describe('ios/plist-bundle-id-format', () => { + it('errors when CFBundleIdentifier is absent', async () => { + const f = await plistBundleIdFormat.run(ctxFor(`CFBundleVersion1`)) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('errors on an underscore / space bundle id', async () => { + const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifiercom.my_app.bad id`)) + expect(f.some(x => x.severity === 'error' && x.title.includes('com.my_app.bad id'))).toBe(true) + }) + it('errors on a single-segment (non reverse-DNS) id', async () => { + const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifierjustone`)) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('skips an unresolved $() reference (no matching pbxproj setting)', async () => { + const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifier$(UNRESOLVED_ID)`, { pbx: '' })) + expect(f).toEqual([]) + }) + it('passes a literal reverse-DNS id', async () => { + const f = await plistBundleIdFormat.run(ctxFor(`CFBundleIdentifierapp.capgo.plugin.TutorialBuild`)) + expect(f).toEqual([]) + }) +}) + +describe('ios/plist-version-short-format', () => { + it('errors on a non-numeric marketing version (ITMS-90060)', async () => { + const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.0-beta`)) + expect(f.some(x => x.severity === 'error' && x.title.includes('1.0-beta'))).toBe(true) + }) + it('errors on a 4-segment version', async () => { + const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.2.3.4`)) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('SKIPS when the key is absent (presence owned by infoplist-sanity)', async () => { + const f = await plistVersionShortFormat.run(ctxFor(`CFBundleVersion1`)) + expect(f).toEqual([]) + }) + it('skips an unresolved $() reference', async () => { + const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString$(MARKETING_VERSION)`, { pbx: '' })) + expect(f).toEqual([]) + }) + it('passes 1.4.2', async () => { + const f = await plistVersionShortFormat.run(ctxFor(`CFBundleShortVersionString1.4.2`)) + expect(f).toEqual([]) + }) +}) + +describe('ios/plist-version-build-format', () => { + it('errors on a non-numeric build version', async () => { + const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleVersionbuild-42`)) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('SKIPS when the key is absent', async () => { + const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleShortVersionString1.0`)) + expect(f).toEqual([]) + }) + it('passes a numeric build (42)', async () => { + const f = await plistVersionBuildFormat.run(ctxFor(`CFBundleVersion42`)) + expect(f).toEqual([]) + }) +}) + +describe('ios/plist-encryption-compliance (upload-gated)', () => { + const uploadCreds = { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' } + it('does not apply when not uploading', () => { + const ctx = ctxFor(HEALTHY_BODY) + expect(plistEncryptionCompliance.appliesTo?.(ctx)).toBe(false) + }) + it('warns when uploading and the key is missing', async () => { + const ctx = ctxFor(HEALTHY_BODY, { partial: { credentials: uploadCreds, distributionMode: 'app_store' } }) + expect(plistEncryptionCompliance.appliesTo?.(ctx)).toBe(true) + const f = await plistEncryptionCompliance.run(ctx) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) + it('passes when uploading and the key is present', async () => { + const body = `${HEALTHY_BODY}ITSAppUsesNonExemptEncryption` + const ctx = ctxFor(body, { partial: { credentials: uploadCreds, distributionMode: 'app_store' } }) + expect(await plistEncryptionCompliance.run(ctx)).toEqual([]) + }) +}) + +describe('ios/plist-ats-arbitrary-loads', () => { + it('warns when NSAllowsArbitraryLoads is true (no upload)', async () => { + const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads` + const f = await plistAtsArbitraryLoads.run(ctxFor(body)) + expect(f.length).toBe(1) + expect(f[0].severity).toBe('warning') + }) + it('escalates to error on upload + server.url dev config', async () => { + const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads` + const ctx = ctxFor(body, { partial: { + credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' }, + distributionMode: 'app_store', + config: { appId: 'x', appName: 'x', webDir: 'd', server: { url: 'http://10.0.0.2:3000' } } as never, + } }) + const f = await plistAtsArbitraryLoads.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('no finding when NSAppTransportSecurity is absent', async () => { + expect(await plistAtsArbitraryLoads.run(ctxFor(HEALTHY_BODY))).toEqual([]) + }) + it('no finding when NSAllowsArbitraryLoads is false', async () => { + const body = `${HEALTHY_BODY}NSAppTransportSecurityNSAllowsArbitraryLoads` + expect(await plistAtsArbitraryLoads.run(ctxFor(body))).toEqual([]) + }) + it('still detects NSAllowsArbitraryLoads=true when a nested NSExceptionDomains dict precedes it', async () => { + // The nested NSExceptionDomains dict comes BEFORE NSAllowsArbitraryLoads — + // a first-`` capture would truncate the ATS block and miss the flag. + const ats = `NSAppTransportSecurity` + + `NSExceptionDomainsexample.comNSIncludesSubdomains` + + `NSAllowsArbitraryLoads` + + `` + const f = await plistAtsArbitraryLoads.run(ctxFor(`${HEALTHY_BODY}${ats}`)) + expect(f.length).toBe(1) + expect(f[0].severity).toBe('warning') + }) +}) + +describe('ios/plist-launch-storyboard', () => { + it('errors when neither UILaunchStoryboardName nor UILaunchScreen is present', async () => { + const f = await plistLaunchStoryboard.run(ctxFor(`CFBundleVersion1`)) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + it('passes with UILaunchScreen dict', async () => { + const f = await plistLaunchStoryboard.run(ctxFor(`UILaunchScreen`)) + expect(f).toEqual([]) + }) +}) + +describe('ios/plist-orientations-multitasking', () => { + it('does not apply when device family lacks iPad (no "2")', () => { + const pbx = PBX.replace('TARGETED_DEVICE_FAMILY = "1,2";', 'TARGETED_DEVICE_FAMILY = "1";') + const ctx = ctxFor(HEALTHY_BODY, { pbx }) + expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(false) + }) + it('does not apply when UIRequiresFullScreen is true', () => { + const body = `${HEALTHY_BODY}UIRequiresFullScreen` + const ctx = ctxFor(body) + expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(false) + }) + it('warns when the ~ipad array is missing an orientation', async () => { + const body = HEALTHY_BODY.replace('UIInterfaceOrientationPortraitUpsideDown\n', '') + const ctx = ctxFor(body) + expect(plistOrientationsMultitasking.appliesTo?.(ctx)).toBe(true) + const f = await plistOrientationsMultitasking.run(ctx) + expect(f.some(x => x.severity === 'warning' && x.title.includes('PortraitUpsideDown'))).toBe(true) + }) + it('passes when ~ipad has all four (grounding)', async () => { + const ctx = ctxFor(HEALTHY_BODY) + expect(await plistOrientationsMultitasking.run(ctx)).toEqual([]) + }) +}) + +describe('ios/plist-orientations-present', () => { + it('warns when the key is absent', async () => { + const f = await plistOrientationsPresent.run(ctxFor(`CFBundleVersion1`)) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) + it('warns when present but empty', async () => { + const f = await plistOrientationsPresent.run(ctxFor(`UISupportedInterfaceOrientations`)) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) + it('warns naming an invalid token', async () => { + const f = await plistOrientationsPresent.run(ctxFor(`UISupportedInterfaceOrientationsUIInterfaceOrientationSideways`)) + expect(f.some(x => x.severity === 'warning' && x.title.includes('UIInterfaceOrientationSideways'))).toBe(true) + }) + it('passes the grounding three-orientation iPhone array', async () => { + expect(await plistOrientationsPresent.run(ctxFor(HEALTHY_BODY))).toEqual([]) + }) +}) + +describe('ios/plist-display-name', () => { + it('warns when both display name and name are missing/empty', async () => { + const f = await plistDisplayName.run(ctxFor(`CFBundleVersion1`)) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) + it('warns when both still unresolved $() with no pbx match', async () => { + const body = `CFBundleDisplayName$(NO_SUCH)CFBundleName$(PRODUCT_NAME)` + const f = await plistDisplayName.run(ctxFor(body, { pbx: '' })) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) + it('passes a literal display name (grounding)', async () => { + expect(await plistDisplayName.run(ctxFor(HEALTHY_BODY))).toEqual([]) + }) + it('passes when only CFBundleName resolves via pbx', async () => { + const body = `CFBundleName$(PRODUCT_NAME)` + expect(await plistDisplayName.run(ctxFor(body))).toEqual([]) + }) +}) + +describe('ios/plist-background-modes-sanity', () => { + it('does not apply without UIBackgroundModes', () => { + expect(plistBackgroundModesSanity.appliesTo?.(ctxFor(HEALTHY_BODY))).toBe(false) + }) + it('warns on an invalid background mode token', async () => { + const body = `${HEALTHY_BODY}UIBackgroundModesteleport` + const ctx = ctxFor(body) + expect(plistBackgroundModesSanity.appliesTo?.(ctx)).toBe(true) + const f = await plistBackgroundModesSanity.run(ctx) + expect(f.some(x => x.severity === 'warning' && x.title.includes('teleport'))).toBe(true) + }) + it('passes a valid mode (audio) when not uploading', async () => { + const body = `${HEALTHY_BODY}UIBackgroundModesaudio` + expect(await plistBackgroundModesSanity.run(ctxFor(body))).toEqual([]) + }) + it('warns on location without a usage string ONLY when uploading', async () => { + const body = `${HEALTHY_BODY}UIBackgroundModeslocation` + // not uploading -> no 2.5.4 sub-check finding + expect(await plistBackgroundModesSanity.run(ctxFor(body))).toEqual([]) + // uploading -> warns + const ctx = ctxFor(body, { partial: { + credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' }, + distributionMode: 'app_store', + } }) + const f = await plistBackgroundModesSanity.run(ctx) + expect(f.some(x => x.severity === 'warning' && /location/i.test(`${x.title} ${x.detail ?? ''}`))).toBe(true) + }) + it('passes location WITH a usage string when uploading', async () => { + const body = `${HEALTHY_BODY}UIBackgroundModeslocationNSLocationWhenInUseUsageDescriptionTo show nearby places` + const ctx = ctxFor(body, { partial: { + credentials: { APPLE_KEY_ID: 'ABCDE12345', APPLE_ISSUER_ID: '12345678-1234-1234-1234-123456789012', APPLE_KEY_CONTENT: 'x' }, + distributionMode: 'app_store', + } }) + expect(await plistBackgroundModesSanity.run(ctx)).toEqual([]) + }) +}) + +describe('partial / non-Capacitor project — all checks early-return []', () => { + it('no Info.plist -> [] (no crash)', async () => { + const dir = makeProject({}) + const ctx = makeCtx({ projectDir: dir, platform: 'ios' }) + for (const check of ALL_CHECKS) + expect(await runGated(check, ctx)).toEqual([]) + }) +}) diff --git a/cli/test/prescan/checks-ios-pods-assets.test.ts b/cli/test/prescan/checks-ios-pods-assets.test.ts new file mode 100644 index 0000000000..8e7c27cc32 --- /dev/null +++ b/cli/test/prescan/checks-ios-pods-assets.test.ts @@ -0,0 +1,479 @@ +// test/prescan/checks-ios-pods-assets.test.ts +// +// §2.E (Pods / SPM) + §2.F (App icons / assets) of the iOS prescan expansion. +// +// Two fixture shapes: a CocoaPods-shaped project (Podfile + Pods/ + App.xcworkspace) +// and an SPM-shaped project (CapApp-SPM/Package.swift + Package.resolved). Both clean +// shapes scan with ZERO findings — the SPM clean case mirrors the real grounding +// tutorial-app exactly (single 1024 AppIcon, Package.resolved under the xcodeproj). +import { describe, expect, it } from 'bun:test' +import { + appiconEmptyOrPlaceholder, + appiconMarketingMissing, + appiconReferencedFileMissing, + podsCapacitorMissing, + podsLockMissing, + podsNotInstalled, + spmCapacitorDependencyMissing, + spmDeploymentTargetConsistency, + spmPackageResolvedMissing, +} from '../../src/build/prescan/checks/ios-pods-assets' +import { makeCtx, makeProject } from './helpers' + +// --------------------------------------------------------------------------- +// Fixture builders +// --------------------------------------------------------------------------- + +// Standard Capacitor Podfile (uses the capacitor_pods helper, NOT a literal pod 'Capacitor'). +const CAPACITOR_PODFILE = `require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '14.0' +use_frameworks! + +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' +end + +target 'App' do + capacitor_pods +end` + +// Standard CLI-managed SPM manifest (depends on capacitor-swift-pm + .product Capacitor). +const SPM_PACKAGE_SWIFT = `// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.1") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm") + ] + ) + ] +)` + +const PACKAGE_RESOLVED = `{ + "originHash" : "abc123", + "pins" : [], + "version" : 3 +}` + +// pbxproj with a Release IPHONEOS_DEPLOYMENT_TARGET=15.0 (matches grounding). +function pbxproj(deploymentTarget = '15.0', iconName = 'AppIcon'): string { + return `// !$*UTF8*$! +{ + ABCD0001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = ${deploymentTarget}; + ASSETCATALOG_COMPILER_APPICON_NAME = ${iconName}; + PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; + }; + name = Release; + }; +}` +} + +const APPICON_CONTENTS_1024 = `{ + "images": [ + { + "idiom": "universal", + "size": "1024x1024", + "filename": "AppIcon-512@2x.png", + "platform": "ios" + } + ], + "info": { "author": "xcode", "version": 1 } +}` + +// A healthy SPM project mirroring the grounding tutorial-app layout. +function cleanSpmFiles(extra: Record = {}): Record { + return { + 'ios/App/CapApp-SPM/Package.swift': SPM_PACKAGE_SWIFT, + 'ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved': PACKAGE_RESOLVED, + 'ios/App/App.xcodeproj/project.pbxproj': pbxproj(), + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': APPICON_CONTENTS_1024, + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png': 'PNGDATA', + ...extra, + } +} + +// A healthy CocoaPods project: Podfile + Podfile.lock + Pods/ + App.xcworkspace + appicon. +function cleanPodsFiles(extra: Record = {}): Record { + return { + 'ios/App/Podfile': CAPACITOR_PODFILE, + 'ios/App/Podfile.lock': 'PODFILE CHECKSUM: abc', + 'ios/App/Pods/Manifest.lock': 'PODFILE CHECKSUM: abc', + 'ios/App/App.xcworkspace/contents.xcworkspacedata': '', + 'ios/App/App.xcodeproj/project.pbxproj': pbxproj(), + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': APPICON_CONTENTS_1024, + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png': 'PNGDATA', + ...extra, + } +} + +// --------------------------------------------------------------------------- +// §2.E Pods checks +// --------------------------------------------------------------------------- + +describe('ios/pods-not-installed', () => { + it('does NOT apply to an SPM project (no Podfile)', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(podsNotInstalled.appliesTo?.(ctx) ?? true).toBe(false) + }) + + it('is clean when Pods/ and App.xcworkspace both exist', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(await podsNotInstalled.run(ctx)).toEqual([]) + }) + + it('warns (not errors — builder runs pod install) when Pods/ is missing', async () => { + const files = cleanPodsFiles() + delete files['ios/App/Pods/Manifest.lock'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await podsNotInstalled.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'warning')).toBe(true) + expect(f.every(x => x.id === 'ios/pods-not-installed')).toBe(true) + }) + + it('warns when App.xcworkspace is missing', async () => { + const files = cleanPodsFiles() + delete files['ios/App/App.xcworkspace/contents.xcworkspacedata'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await podsNotInstalled.run(ctx) + expect(f.some(x => x.severity === 'warning')).toBe(true) + }) +}) + +describe('ios/pods-lock-missing', () => { + it('is clean when Podfile.lock exists', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(await podsLockMissing.run(ctx)).toEqual([]) + }) + + it('warns when Podfile.lock is absent', async () => { + const files = cleanPodsFiles() + delete files['ios/App/Podfile.lock'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await podsLockMissing.run(ctx) + expect(f.some(x => x.severity === 'warning' && x.id === 'ios/pods-lock-missing')).toBe(true) + }) + + it('does NOT apply to an SPM project', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(podsLockMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) +}) + +describe('ios/pods-capacitor-missing', () => { + it('is clean when the Podfile wires Capacitor (capacitor_pods helper)', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(await podsCapacitorMissing.run(ctx)).toEqual([]) + }) + + it('warns (not errors — high FP, builder builds from Podfile) when no Capacitor pod', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanPodsFiles({ + 'ios/App/Podfile': `platform :ios, '14.0'\ntarget 'App' do\n pod 'SomethingElse'\nend`, + })), + }) + const f = await podsCapacitorMissing.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'warning' && x.id === 'ios/pods-capacitor-missing')).toBe(true) + }) + + it('does NOT apply to an SPM project', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(podsCapacitorMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// §2.E SPM checks +// --------------------------------------------------------------------------- + +describe('ios/spm-package-resolved-missing', () => { + it('is clean when Package.resolved exists under the xcodeproj (grounding layout)', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(await spmPackageResolvedMissing.run(ctx)).toEqual([]) + }) + + it('is clean when Package.resolved lives next to Package.swift instead', async () => { + const files = cleanSpmFiles() + delete files['ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved'] + files['ios/App/CapApp-SPM/Package.resolved'] = PACKAGE_RESOLVED + const ctx = makeCtx({ projectDir: makeProject(files) }) + expect(await spmPackageResolvedMissing.run(ctx)).toEqual([]) + }) + + it('warns (not errors — builder resolves SPM during build_app) when neither Package.resolved exists', async () => { + const files = cleanSpmFiles() + delete files['ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await spmPackageResolvedMissing.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'warning' && x.id === 'ios/spm-package-resolved-missing')).toBe(true) + }) + + it('does NOT apply to a Pods project (no Package.swift)', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(spmPackageResolvedMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) +}) + +describe('ios/spm-capacitor-dependency-missing', () => { + it('is clean when Package.swift declares capacitor-swift-pm + .product Capacitor', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(await spmCapacitorDependencyMissing.run(ctx)).toEqual([]) + }) + + it('warns (not errors — builder does not cap-sync but high FP) when capacitor dep absent', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/CapApp-SPM/Package.swift': `// swift-tools-version: 5.9 +import PackageDescription +let package = Package(name: "CapApp-SPM", platforms: [.iOS(.v15)], dependencies: [], targets: [])`, + })), + }) + const f = await spmCapacitorDependencyMissing.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'warning' && x.id === 'ios/spm-capacitor-dependency-missing')).toBe(true) + }) + + it('does NOT apply to a Pods project', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(spmCapacitorDependencyMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// §2.F App icon checks +// --------------------------------------------------------------------------- + +describe('ios/appicon-empty-or-placeholder', () => { + it('is clean when the appiconset has a real icon image (grounding)', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(await appiconEmptyOrPlaceholder.run(ctx)).toEqual([]) + }) + + it('errors when the AppIcon.appiconset directory is missing', async () => { + const files = cleanSpmFiles() + delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json'] + delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await appiconEmptyOrPlaceholder.run(ctx) + expect(f.some(x => x.id === 'ios/appicon-empty-or-placeholder')).toBe(true) + }) + + it('warns (not errors — actool tolerates empty for ad_hoc) when no icon images and NOT uploading', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ "images": [], "info": { "version": 1 } }`, + })), + distributionMode: 'ad_hoc', + }) + const f = await appiconEmptyOrPlaceholder.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'warning')).toBe(true) + }) + + it('escalates to error when no icon images AND uploading to App Store (ITMS-90704 will block)', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ "images": [], "info": { "version": 1 } }`, + })), + distributionMode: 'app_store', + credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' }, + }) + const f = await appiconEmptyOrPlaceholder.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) + + it('errors when Contents.json is malformed', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ not valid json`, + })), + distributionMode: 'app_store', + credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' }, + }) + const f = await appiconEmptyOrPlaceholder.run(ctx) + expect(f.some(x => x.severity === 'error')).toBe(true) + }) +}) + +describe('ios/appicon-referenced-file-missing', () => { + it('is clean when every referenced filename exists on disk (grounding)', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(await appiconReferencedFileMissing.run(ctx)).toEqual([]) + }) + + it('does NOT apply when Contents.json is absent', () => { + const files = cleanSpmFiles() + delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + expect(appiconReferencedFileMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) + + it('ERRORS (actool fails server-side) when a referenced PNG is missing from disk', async () => { + const files = cleanSpmFiles() + delete files['ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png'] + const ctx = makeCtx({ projectDir: makeProject(files) }) + const f = await appiconReferencedFileMissing.run(ctx) + expect(f.length).toBeGreaterThan(0) + expect(f.every(x => x.severity === 'error' && x.id === 'ios/appicon-referenced-file-missing')).toBe(true) + // Filename is surfaced; no credential material ever appears in findings. + expect(f.some(x => (x.detail ?? '').includes('AppIcon-512@2x.png'))).toBe(true) + }) +}) + +describe('ios/appicon-marketing-missing', () => { + it('does NOT apply when not uploading to App Store (ad_hoc)', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()), distributionMode: 'ad_hoc' }) + expect(appiconMarketingMissing.appliesTo?.(ctx) ?? true).toBe(false) + }) + + it('is clean (1024 present) when uploading to App Store', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles()), + distributionMode: 'app_store', + credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' }, + }) + expect(await appiconMarketingMissing.run(ctx)).toEqual([]) + }) + + it('errors (upload-gated, ITMS-90704) when the 1024 marketing icon is absent', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': `{ + "images": [{ "idiom": "iphone", "size": "60x60", "scale": "3x", "filename": "icon-60.png" }], + "info": { "version": 1 } + }`, + })), + distributionMode: 'app_store', + credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' }, + }) + const f = await appiconMarketingMissing.run(ctx) + expect(f.some(x => x.severity === 'error' && x.id === 'ios/appicon-marketing-missing')).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// §2.F SPM deployment-target consistency +// --------------------------------------------------------------------------- + +describe('ios/spm-deployment-target-consistency', () => { + it('is clean when pbxproj target >= Package.swift min (grounding 15.0 vs .v15)', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()) }) + expect(await spmDeploymentTargetConsistency.run(ctx)).toEqual([]) + }) + + it('warns when pbxproj target < Package.swift min (the dangerous direction)', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App.xcodeproj/project.pbxproj': pbxproj('14.0'), + })), + }) + const f = await spmDeploymentTargetConsistency.run(ctx) + expect(f.some(x => x.severity === 'warning' && x.id === 'ios/spm-deployment-target-consistency')).toBe(true) + }) + + it('is clean when pbxproj target > Package.swift min', async () => { + const ctx = makeCtx({ + projectDir: makeProject(cleanSpmFiles({ + 'ios/App/App.xcodeproj/project.pbxproj': pbxproj('16.0'), + })), + }) + expect(await spmDeploymentTargetConsistency.run(ctx)).toEqual([]) + }) + + it('does NOT apply when IPHONEOS_DEPLOYMENT_TARGET is absent (inherited/unknown)', () => { + const files = cleanSpmFiles() + files['ios/App/App.xcodeproj/project.pbxproj'] = `// !$*UTF8*$! +{ + ABCD0001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { PRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; }; + name = Release; + }; +}` + const ctx = makeCtx({ projectDir: makeProject(files) }) + expect(spmDeploymentTargetConsistency.appliesTo?.(ctx) ?? true).toBe(false) + }) + + it('does NOT apply to a Pods project', () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()) }) + expect(spmDeploymentTargetConsistency.appliesTo?.(ctx) ?? true).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Degradation / no-crash on partial projects +// --------------------------------------------------------------------------- + +describe('ios-pods-assets - partial / non-Capacitor projects never crash', () => { + const ALL = [ + podsNotInstalled, + podsLockMissing, + podsCapacitorMissing, + spmPackageResolvedMissing, + spmCapacitorDependencyMissing, + appiconEmptyOrPlaceholder, + appiconReferencedFileMissing, + appiconMarketingMissing, + spmDeploymentTargetConsistency, + ] + + it('every check early-returns [] on an empty project dir - except appicon-empty (always reports a missing set)', async () => { + const ctx = makeCtx({ projectDir: makeProject({}) }) + for (const check of ALL) { + if (check.appliesTo && !check.appliesTo(ctx)) + continue + // appicon-empty-or-placeholder is "always (iOS, degrade in run)" per §3: a + // project with no AppIcon set must surface that, not stay silent. + if (check.id === 'ios/appicon-empty-or-placeholder') { + const f = await check.run(ctx) + expect(f.length).toBe(1) + expect(f[0].id).toBe('ios/appicon-empty-or-placeholder') + expect(f[0].title).toContain('AppIcon.appiconset is missing') + continue + } + expect(await check.run(ctx)).toEqual([]) + } + }) + + it('full clean SPM project (grounding mirror) yields ZERO findings across all checks', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanSpmFiles()), distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } }) + for (const check of ALL) { + if (check.appliesTo && !check.appliesTo(ctx)) + continue + expect(await check.run(ctx)).toEqual([]) + } + }) + + it('full clean CocoaPods project yields ZERO findings across all checks', async () => { + const ctx = makeCtx({ projectDir: makeProject(cleanPodsFiles()), distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } }) + for (const check of ALL) { + if (check.appliesTo && !check.appliesTo(ctx)) + continue + expect(await check.run(ctx)).toEqual([]) + } + }) +}) diff --git a/cli/test/prescan/checks-ios-xcode.test.ts b/cli/test/prescan/checks-ios-xcode.test.ts new file mode 100644 index 0000000000..d78f517290 --- /dev/null +++ b/cli/test/prescan/checks-ios-xcode.test.ts @@ -0,0 +1,453 @@ +// test/prescan/checks-ios-xcode.test.ts +// +// Tests for the §2.B "Xcode project / build settings" check pack +// (checks/ios-xcode.ts). The clean fixture mirrors the real Capacitor-8 SPM +// tutorial project (Debug==Release bundle id, DEVELOPMENT_TEAM present, no +// ENABLE_BITCODE, SWIFT_VERSION 5.0, IPHONEOS_DEPLOYMENT_TARGET 15.0, a single +// application target) and MUST scan clean (zero findings) — it is the +// regression baseline. Each failing case is a minimal mutation of that fixture. +import type { ScanContext } from '../../src/build/prescan/types' +import { describe, expect, it } from 'bun:test' +import { + bundleIdMismatchAcrossConfigs, + deploymentTargetCapacitor, + enableBitcodeLeftover, + multipleAppTargets, + noAppTarget, + signingTeam, + swiftVersionSanity, +} from '../../src/build/prescan/checks/ios-xcode' +import { makeProject } from './helpers' + +const CAP8_PKG = JSON.stringify({ dependencies: { '@capacitor/core': '^8.0.0', '@capacitor/ios': '^8.0.0' } }) + +/** + * Build a pbxproj from per-config settings dicts, modelled on the Capacitor SPM + * layout: one project-level Debug/Release pair carrying only deployment target, + * and one app-target Debug/Release pair carrying the signing/version settings. + * `extraTargets` lets a test add extra targets (for the multi-target case); + * includeAppTarget:false drops the application target entirely. + */ +function makePbx(opts: { + projectSettings?: Record + targetDebug?: Record + targetRelease?: Record + extraTargets?: { name: string, productType: string, settings: Record }[] + includeAppTarget?: boolean +} = {}): string { + const renderSettings = (s: Record): string => + Object.entries(s) + .map(([k, v]) => `\t\t\t\t${k} = ${/[\s,]/.test(v) ? `"${v}"` : v};`) + .join('\n') + + const projectSettings = opts.projectSettings ?? { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' } + const includeApp = opts.includeAppTarget !== false + const targetDebug = opts.targetDebug ?? { + CODE_SIGN_STYLE: 'Automatic', + CURRENT_PROJECT_VERSION: '1', + DEVELOPMENT_TEAM: 'UVTJ336J2D', + IPHONEOS_DEPLOYMENT_TARGET: '15.0', + MARKETING_VERSION: '1.0', + PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', + PRODUCT_NAME: 'Tutorial Build example app', + SWIFT_VERSION: '5.0', + TARGETED_DEVICE_FAMILY: '1,2', + } + const targetRelease = opts.targetRelease ?? { ...targetDebug } + + const targetBlocks: string[] = [] + const configBlocks: string[] = [] + const listBlocks: string[] = [] + const projectTargetRefs: string[] = [] + + if (includeApp) { + targetBlocks.push(`\t\tAAA1 /* App */ = { +\t\t\tisa = PBXNativeTarget; +\t\t\tbuildConfigurationList = LIST_APP /* Build configuration list for PBXNativeTarget "App" */; +\t\t\tname = App; +\t\t\tproductType = "com.apple.product-type.application"; +\t\t};`) + configBlocks.push(`\t\tCFG_APP_DEBUG /* Debug */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +${renderSettings(targetDebug)} +\t\t\t}; +\t\t\tname = Debug; +\t\t};`) + configBlocks.push(`\t\tCFG_APP_RELEASE /* Release */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +${renderSettings(targetRelease)} +\t\t\t}; +\t\t\tname = Release; +\t\t};`) + listBlocks.push(`\t\tLIST_APP /* Build configuration list for PBXNativeTarget "App" */ = { +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\tCFG_APP_DEBUG /* Debug */, +\t\t\t\tCFG_APP_RELEASE /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationName = Release; +\t\t};`) + projectTargetRefs.push('\t\t\t\tAAA1 /* App */,') + } + + let i = 0 + for (const t of opts.extraTargets ?? []) { + i++ + const id = `EXTRA${i}` + targetBlocks.push(`\t\t${id} /* ${t.name} */ = { +\t\t\tisa = PBXNativeTarget; +\t\t\tbuildConfigurationList = LIST_${id} /* Build configuration list for PBXNativeTarget "${t.name}" */; +\t\t\tname = ${t.name}; +\t\t\tproductType = "${t.productType}"; +\t\t};`) + configBlocks.push(`\t\tCFG_${id}_RELEASE /* Release */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +${renderSettings(t.settings)} +\t\t\t}; +\t\t\tname = Release; +\t\t};`) + listBlocks.push(`\t\tLIST_${id} /* Build configuration list for PBXNativeTarget "${t.name}" */ = { +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\tCFG_${id}_RELEASE /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationName = Release; +\t\t};`) + projectTargetRefs.push(`\t\t\t\t${id} /* ${t.name} */,`) + } + + return `// !$*UTF8*$! +{ +\tobjects = { +/* Begin PBXNativeTarget section */ +${targetBlocks.join('\n')} +/* End PBXNativeTarget section */ +/* Begin PBXProject section */ +\t\tPROJ /* Project object */ = { +\t\t\tisa = PBXProject; +\t\t\tattributes = { +\t\t\t\tTargetAttributes = { +\t\t\t\t\tAAA1 = { ProvisioningStyle = Automatic; }; +\t\t\t\t}; +\t\t\t}; +\t\t\tbuildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */; +\t\t\ttargets = ( +${projectTargetRefs.join('\n')} +\t\t\t); +\t\t}; +/* End PBXProject section */ +/* Begin XCBuildConfiguration section */ +\t\tCFG_PROJ_DEBUG /* Debug */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +${renderSettings(projectSettings)} +\t\t\t}; +\t\t\tname = Debug; +\t\t}; +\t\tCFG_PROJ_RELEASE /* Release */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +${renderSettings(projectSettings)} +\t\t\t}; +\t\t\tname = Release; +\t\t}; +${configBlocks.join('\n')} +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ +\t\tLIST_PROJECT /* Build configuration list for PBXProject "App" */ = { +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\tCFG_PROJ_DEBUG /* Debug */, +\t\t\t\tCFG_PROJ_RELEASE /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationName = Release; +\t\t}; +${listBlocks.join('\n')} +/* End XCConfigurationList section */ +\t}; +} +` +} + +/** Write a pbxproj into the Capacitor `ios/App/App.xcodeproj` location. */ +function projectWith(pbx: string, extraFiles: Record = {}): string { + return makeProject({ + 'package.json': CAP8_PKG, + 'ios/App/App.xcodeproj/project.pbxproj': pbx, + ...extraFiles, + }) +} + +function ctx(projectDir: string, partial: Partial = {}): ScanContext { + return { appId: 'app.capgo.plugin.TutorialBuild', platform: 'ios', projectDir, ...partial } +} + +// ── Clean grounding baseline ──────────────────────────────────────────────── + +describe('ios-xcode: clean Capacitor-8 SPM fixture scans clean (regression baseline)', () => { + const dir = projectWith(makePbx()) + const checks = [ + deploymentTargetCapacitor, + signingTeam, + bundleIdMismatchAcrossConfigs, + enableBitcodeLeftover, + swiftVersionSanity, + noAppTarget, + multipleAppTargets, + ] + for (const check of checks) { + it(`${check.id} returns [] on the clean fixture`, async () => { + const c = ctx(dir) + const applies = check.appliesTo ? check.appliesTo(c) : true + const findings = applies ? await check.run(c) : [] + expect(findings).toEqual([]) + }) + } +}) + +describe('ios-xcode: grounding against a real-shaped pbxproj fixture (every check clean)', () => { + // Self-contained inline fixture mirroring the real Capacitor-8 SPM tutorial + // project's distinctive build settings (real bundle id, team, AppIcon name, + // version pair) so the grounding assertions are REAL on CI, where the external + // tutorial-app checkout does not exist. Previously this read an absolute path + // outside the repo, which made the grounding pass vacuously / crash on CI. + const realShapedTarget = { + ASSETCATALOG_COMPILER_APPICON_NAME: 'AppIcon', + CODE_SIGN_STYLE: 'Automatic', + CURRENT_PROJECT_VERSION: '1', + DEVELOPMENT_TEAM: 'UVTJ336J2D', + IPHONEOS_DEPLOYMENT_TARGET: '15.0', + MARKETING_VERSION: '1.0', + PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', + PRODUCT_NAME: 'Tutorial Build example app', + SWIFT_VERSION: '5.0', + TARGETED_DEVICE_FAMILY: '1,2', + } + const realDir = projectWith(makePbx({ + projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', SDKROOT: 'iphoneos' }, + targetDebug: { ...realShapedTarget }, + targetRelease: { ...realShapedTarget }, + })) + const checks = [ + deploymentTargetCapacitor, + signingTeam, + bundleIdMismatchAcrossConfigs, + enableBitcodeLeftover, + swiftVersionSanity, + noAppTarget, + multipleAppTargets, + ] + for (const check of checks) { + it(`${check.id} returns [] against the real-shaped fixture`, async () => { + const c = ctx(realDir) + const applies = check.appliesTo ? check.appliesTo(c) : true + const findings = applies ? await check.run(c) : [] + expect(findings).toEqual([]) + }) + } +}) + +// ── ios/xcode-deployment-target-capacitor ─────────────────────────────────── + +describe('ios/xcode-deployment-target-capacitor', () => { + it('errors when IPHONEOS_DEPLOYMENT_TARGET is below the Capacitor 8 floor (14)', async () => { + const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '12.0', SDKROOT: 'iphoneos' } })) + expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(true) + const findings = await deploymentTargetCapacitor.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('error') + expect(findings[0].id).toBe('ios/xcode-deployment-target-capacitor') + expect(findings[0].title).toContain('12') + }) + it('passes at exactly the floor (14.0 on Cap8)', async () => { + const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '14.0', SDKROOT: 'iphoneos' } })) + expect(await deploymentTargetCapacitor.run(ctx(dir))).toEqual([]) + }) + it('reads the app-target value too (target below floor errors even if project-level is fine)', async () => { + const low = { IPHONEOS_DEPLOYMENT_TARGET: '11.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', DEVELOPMENT_TEAM: 'UVTJ336J2D', CODE_SIGN_STYLE: 'Automatic', SWIFT_VERSION: '5.0' } + const dir = projectWith(makePbx({ targetDebug: { ...low }, targetRelease: { ...low }, projectSettings: { SDKROOT: 'iphoneos' } })) + const findings = await deploymentTargetCapacitor.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('error') + }) + it('appliesTo false when the key is absent everywhere (inherited)', async () => { + const noTarget = { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', DEVELOPMENT_TEAM: 'UVTJ336J2D', CODE_SIGN_STYLE: 'Automatic', SWIFT_VERSION: '5.0' } + const dir = projectWith(makePbx({ projectSettings: { SDKROOT: 'iphoneos' }, targetDebug: { ...noTarget }, targetRelease: { ...noTarget } })) + expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(false) + }) + it('appliesTo false when capacitorMajor is null (no package.json)', async () => { + const dir = makeProject({ 'ios/App/App.xcodeproj/project.pbxproj': makePbx() }) + expect(deploymentTargetCapacitor.appliesTo!(ctx(dir))).toBe(false) + }) + it('does not throw on a missing project (returns [])', async () => { + const dir = makeProject({ 'package.json': CAP8_PKG }) + expect(await deploymentTargetCapacitor.run(ctx(dir))).toEqual([]) + }) +}) + +// ── ios/xcode-signing-team ─────────────────────────────────────────────────── + +describe('ios/xcode-signing-team', () => { + const noTeam = { + CODE_SIGN_STYLE: 'Automatic', + IPHONEOS_DEPLOYMENT_TARGET: '15.0', + PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', + SWIFT_VERSION: '5.0', + } + it('warns (no upload) when CODE_SIGN_STYLE present but DEVELOPMENT_TEAM absent', async () => { + const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } })) + expect(signingTeam.appliesTo!(ctx(dir))).toBe(true) + const findings = await signingTeam.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('warning') + expect(findings[0].id).toBe('ios/xcode-signing-team') + }) + it('escalates to error when uploading to the App Store', async () => { + const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } })) + const c = ctx(dir, { distributionMode: 'app_store', credentials: { APPLE_KEY_ID: 'k', APPLE_ISSUER_ID: 'i', APPLE_KEY_CONTENT: 'c' } }) + const findings = await signingTeam.run(c) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('error') + }) + it('treats an empty-string DEVELOPMENT_TEAM as missing', async () => { + const dir = projectWith(makePbx({ targetDebug: { ...noTeam, DEVELOPMENT_TEAM: '' }, targetRelease: { ...noTeam, DEVELOPMENT_TEAM: '' } })) + expect((await signingTeam.run(ctx(dir))).length).toBe(1) + }) + it('also fires for Manual signing without a team', async () => { + const manual = { ...noTeam, CODE_SIGN_STYLE: 'Manual' } + const dir = projectWith(makePbx({ targetDebug: { ...manual }, targetRelease: { ...manual } })) + expect((await signingTeam.run(ctx(dir))).length).toBe(1) + }) + it('suppressed (appliesTo false) when a provisioning map is present', async () => { + const dir = projectWith(makePbx({ targetDebug: { ...noTeam }, targetRelease: { ...noTeam } })) + const c = ctx(dir, { credentials: { CAPGO_IOS_PROVISIONING_MAP: JSON.stringify({ 'app.capgo.plugin.TutorialBuild': { profile: 'AAAA', name: 'p' } }) } }) + expect(signingTeam.appliesTo!(c)).toBe(false) + }) + it('appliesTo false when CODE_SIGN_STYLE is absent (nothing to judge)', async () => { + const noStyle = { IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: '5.0' } + const dir = projectWith(makePbx({ targetDebug: { ...noStyle }, targetRelease: { ...noStyle } })) + expect(signingTeam.appliesTo!(ctx(dir))).toBe(false) + }) +}) + +// ── ios/xcode-bundle-id-mismatch-across-configs ────────────────────────────── + +describe('ios/xcode-bundle-id-mismatch-across-configs', () => { + it('warns when Debug and Release bundle ids differ', async () => { + const dir = projectWith(makePbx({ + targetDebug: { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', SWIFT_VERSION: '5.0', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.debug' }, + targetRelease: { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', SWIFT_VERSION: '5.0', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild' }, + })) + expect(bundleIdMismatchAcrossConfigs.appliesTo!(ctx(dir))).toBe(true) + const findings = await bundleIdMismatchAcrossConfigs.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('warning') + expect(findings[0].id).toBe('ios/xcode-bundle-id-mismatch-across-configs') + expect(findings[0].detail).toContain('app.capgo.plugin.TutorialBuild.debug') + }) + it('passes when Debug == Release (the clean case)', async () => { + const dir = projectWith(makePbx()) + expect(await bundleIdMismatchAcrossConfigs.run(ctx(dir))).toEqual([]) + }) +}) + +// ── ios/xcode-enable-bitcode-leftover ──────────────────────────────────────── + +describe('ios/xcode-enable-bitcode-leftover', () => { + it('warns when ENABLE_BITCODE = YES anywhere', async () => { + const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', ENABLE_BITCODE: 'YES' } })) + expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(true) + const findings = await enableBitcodeLeftover.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('warning') + expect(findings[0].id).toBe('ios/xcode-enable-bitcode-leftover') + }) + it('appliesTo false when ENABLE_BITCODE = NO', async () => { + const dir = projectWith(makePbx({ projectSettings: { IPHONEOS_DEPLOYMENT_TARGET: '15.0', ENABLE_BITCODE: 'NO' } })) + expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(false) + }) + it('appliesTo false when absent (the clean case)', async () => { + const dir = projectWith(makePbx()) + expect(enableBitcodeLeftover.appliesTo!(ctx(dir))).toBe(false) + }) +}) + +// ── ios/xcode-swift-version-sanity ─────────────────────────────────────────── + +describe('ios/xcode-swift-version-sanity', () => { + it('warns when SWIFT_VERSION < 5', async () => { + const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: '4.2' } + const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } })) + expect(swiftVersionSanity.appliesTo!(ctx(dir))).toBe(true) + const findings = await swiftVersionSanity.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('warning') + expect(findings[0].id).toBe('ios/xcode-swift-version-sanity') + }) + it('warns when SWIFT_VERSION is non-numeric', async () => { + const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild', SWIFT_VERSION: 'swift5' } + const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } })) + expect((await swiftVersionSanity.run(ctx(dir))).length).toBe(1) + }) + it('passes at 5.0 (clean)', async () => { + const dir = projectWith(makePbx()) + expect(await swiftVersionSanity.run(ctx(dir))).toEqual([]) + }) + it('appliesTo false when SWIFT_VERSION is absent (Obj-C-only target)', async () => { + const s = { CODE_SIGN_STYLE: 'Automatic', DEVELOPMENT_TEAM: 'UVTJ336J2D', IPHONEOS_DEPLOYMENT_TARGET: '15.0', PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild' } + const dir = projectWith(makePbx({ targetDebug: { ...s }, targetRelease: { ...s } })) + expect(swiftVersionSanity.appliesTo!(ctx(dir))).toBe(false) + }) +}) + +// ── ios/xcode-no-app-target ────────────────────────────────────────────────── + +describe('ios/xcode-no-app-target', () => { + it('errors when the pbxproj parses but has zero application targets', async () => { + const dir = projectWith(makePbx({ + includeAppTarget: false, + extraTargets: [{ name: 'Ext', productType: 'com.apple.product-type.app-extension', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.ext', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }], + })) + expect(noAppTarget.appliesTo!(ctx(dir))).toBe(true) + const findings = await noAppTarget.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('error') + expect(findings[0].id).toBe('ios/xcode-no-app-target') + }) + it('passes with exactly one application target (clean)', async () => { + const dir = projectWith(makePbx()) + expect(await noAppTarget.run(ctx(dir))).toEqual([]) + }) + it('appliesTo false (skip) when readPbxproj is null', async () => { + const dir = makeProject({ 'package.json': CAP8_PKG }) + expect(noAppTarget.appliesTo!(ctx(dir))).toBe(false) + }) +}) + +// ── ios/xcode-multiple-app-targets ─────────────────────────────────────────── + +describe('ios/xcode-multiple-app-targets', () => { + it('warns when two application targets exist', async () => { + const dir = projectWith(makePbx({ + extraTargets: [{ name: 'App2', productType: 'com.apple.product-type.application', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild2', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }], + })) + expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(true) + const findings = await multipleAppTargets.run(ctx(dir)) + expect(findings.length).toBe(1) + expect(findings[0].severity).toBe('warning') + expect(findings[0].id).toBe('ios/xcode-multiple-app-targets') + }) + it('does not count extensions (single app target + extension is clean)', async () => { + const dir = projectWith(makePbx({ + extraTargets: [{ name: 'Ext', productType: 'com.apple.product-type.app-extension', settings: { PRODUCT_BUNDLE_IDENTIFIER: 'app.capgo.plugin.TutorialBuild.ext', IPHONEOS_DEPLOYMENT_TARGET: '15.0' } }], + })) + expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(false) + }) + it('appliesTo false with a single app target (clean)', async () => { + const dir = projectWith(makePbx()) + expect(multipleAppTargets.appliesTo!(ctx(dir))).toBe(false) + }) +}) diff --git a/cli/test/prescan/engine.test.ts b/cli/test/prescan/engine.test.ts index 23be17ce1e..1e4199ffb8 100644 --- a/cli/test/prescan/engine.test.ts +++ b/cli/test/prescan/engine.test.ts @@ -82,16 +82,34 @@ describe('fixture helpers', () => { }) describe('registry', () => { - it('contains all 47 checks with unique ids', () => { + it('contains all 80 checks with unique ids', () => { const ids = ALL_CHECKS.map(c => c.id) expect(new Set(ids).size).toBe(ids.length) - expect(ids.length).toBe(47) + expect(ids.length).toBe(80) for (const expected of [ 'shared/apikey-permission', 'shared/app-exists', 'shared/credentials-saved', 'shared/cap-sync-stale', 'shared/node-linker-layout', 'shared/bundle-id-consistency', 'ios/p12-opens', 'ios/p12-expiry', 'ios/profile-expiry', 'ios/profile-bundle-match', 'ios/profile-type-vs-mode', 'ios/cert-profile-pairing', 'ios/targets-covered', 'ios/infoplist-sanity', 'ios/asc-key-valid', + // 10 ios plist checks + 'ios/plist-bundle-id-format', 'ios/plist-version-short-format', 'ios/plist-version-build-format', + 'ios/plist-encryption-compliance', 'ios/plist-ats-arbitrary-loads', 'ios/plist-launch-storyboard', + 'ios/plist-orientations-multitasking', 'ios/plist-orientations-present', 'ios/plist-display-name', + 'ios/plist-background-modes-sanity', + // 7 ios xcode checks + 'ios/xcode-deployment-target-capacitor', 'ios/xcode-signing-team', 'ios/xcode-bundle-id-mismatch-across-configs', + 'ios/xcode-enable-bitcode-leftover', 'ios/xcode-swift-version-sanity', 'ios/xcode-no-app-target', + 'ios/xcode-multiple-app-targets', + // 4 ios entitlements checks + 'ios/entitlements-vs-profile-capability', 'ios/entitlements-aps-environment-vs-mode', + 'ios/entitlements-associated-domains-format', 'ios/entitlements-app-groups-format', + // 3 ios capacitor config checks + 'ios/capacitor-server-url-shipped', 'ios/capacitor-server-cleartext', 'ios/capacitor-allow-navigation-wildcard', + // 9 ios pods/spm/appicon checks + 'ios/pods-not-installed', 'ios/pods-lock-missing', 'ios/pods-capacitor-missing', + 'ios/spm-package-resolved-missing', 'ios/spm-capacitor-dependency-missing', 'ios/appicon-empty-or-placeholder', + 'ios/appicon-referenced-file-missing', 'ios/appicon-marketing-missing', 'ios/spm-deployment-target-consistency', 'android/keystore-opens', 'android/keystore-expiry', 'android/cordova-vars-present', 'android/gradle-props-heuristics', 'android/play-sa-json', 'android/flavor-exists', 'android/agp8-package-attr', diff --git a/cli/test/prescan/ios-parsers.test.ts b/cli/test/prescan/ios-parsers.test.ts new file mode 100644 index 0000000000..9d71fc97e2 --- /dev/null +++ b/cli/test/prescan/ios-parsers.test.ts @@ -0,0 +1,741 @@ +// test/prescan/ios-parsers.test.ts +// Unit tests for the shared iOS prescan parsing helpers. Each pure parser must +// NEVER throw — it returns null/[]/false on missing or malformed input — and is +// grounded against the real Capacitor-8 SPM tutorial project (which must stay +// false-positive clean). +import { describe, expect, it } from 'bun:test' +import { + plistArrayStrings, + plistBool, + plistDictBlock, + plistHasKey, + plistString, +} from '../../src/build/prescan/checks/ios-plist-read' +import { + readBuildConfigs, + readBuildSetting, + readTargetConfigs, + resolvePlistValue, +} from '../../src/build/prescan/ios-pbxsettings' +import { + entArray, + entBool, + entString, + readAppEntitlements, +} from '../../src/build/prescan/ios-entitlements' +import { parseMobileprovisionDetailedFromBase64 } from '../../src/build/mobileprovision-parser' +import { + appIconSetDir, + hasMarketingIcon, + readContentsJson, +} from '../../src/build/prescan/ios-appicon' +import { Buffer } from 'node:buffer' +import { join } from 'node:path' +import { makeProfileXml, makeProject } from './helpers' + +// Self-contained inline fixtures mirroring the real Capacitor-8 SPM tutorial +// project's Info.plist and App.entitlements byte-for-byte (key set, $()-refs, +// orientation arrays). These keep the grounding assertions REAL on CI, where the +// external tutorial-app checkout does not exist — previously the grounding read +// absolute paths outside the repo (vacuous pass / module-load crash on CI). +const REAL_SHAPED_INFO_PLIST = ` + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Tutorial Build example app + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + +` + +// Real-shaped App.entitlements (aps-environment=development only, like the +// stock Capacitor default). +const REAL_SHAPED_ENTITLEMENTS = ` + + + + aps-environment + development + +` + +// Real-shaped AppIcon Contents.json (single universal 1024 marketing icon). +const REAL_SHAPED_APPICON_CONTENTS = JSON.stringify({ + images: [{ idiom: 'universal', size: '1024x1024', filename: 'AppIcon-512@2x.png', platform: 'ios' }], + info: { author: 'xcode', version: 1 }, +}, null, 2) +const plist = (body: string) => ` + +${body}` + +describe('ios-plist-read: plistString', () => { + it('reads a top-level string value', () => { + expect(plistString(plist('CFBundleNameMyApp'), 'CFBundleName')).toBe('MyApp') + }) + it('returns null when the key is absent', () => { + expect(plistString(plist('Otherx'), 'CFBundleName')).toBeNull() + }) + it('reads an unresolved $() build-variable reference verbatim', () => { + expect(plistString(plist('CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)'), 'CFBundleIdentifier')) + .toBe('$(PRODUCT_BUNDLE_IDENTIFIER)') + }) + it('escapes regex metacharacters in the key (never throws)', () => { + expect(plistString(plist('a.b*cv'), 'a.b*c')).toBe('v') + }) + it('returns null on empty/garbage input', () => { + expect(plistString('', 'CFBundleName')).toBeNull() + expect(plistString('<<<', 'CFBundleName')).toBeNull() + }) +}) + +describe('ios-plist-read: plistBool', () => { + it('reads ', () => { + expect(plistBool(plist('LSRequiresIPhoneOS'), 'LSRequiresIPhoneOS')).toBe(true) + }) + it('reads ', () => { + expect(plistBool(plist('UIRequiresFullScreen'), 'UIRequiresFullScreen')).toBe(false) + }) + it('returns null when the key is absent (distinct from false)', () => { + expect(plistBool(plist('Other'), 'UIRequiresFullScreen')).toBeNull() + }) + it('returns null when the key maps to a non-bool value', () => { + expect(plistBool(plist('CFBundleNamex'), 'CFBundleName')).toBeNull() + }) +}) + +describe('ios-plist-read: plistHasKey', () => { + it('detects presence', () => { + expect(plistHasKey(plist('CFBundleVersion$(X)'), 'CFBundleVersion')).toBe(true) + }) + it('returns false when absent', () => { + expect(plistHasKey(plist('CFBundleVersion1'), 'CFBundleShortVersionString')).toBe(false) + }) +}) + +describe('ios-plist-read: plistArrayStrings', () => { + it('collects the children of an array key', () => { + const body = `UISupportedInterfaceOrientations + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + ` + expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations')).toEqual([ + 'UIInterfaceOrientationPortrait', + 'UIInterfaceOrientationLandscapeLeft', + ]) + }) + it('distinguishes the ~ipad suffixed key from the base key', () => { + const body = `UISupportedInterfaceOrientationsA + UISupportedInterfaceOrientations~ipadBC` + expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations~ipad')).toEqual(['B', 'C']) + expect(plistArrayStrings(plist(body), 'UISupportedInterfaceOrientations')).toEqual(['A']) + }) + it('stops at the first closing tag (non-greedy)', () => { + const body = `AoneBtwo` + expect(plistArrayStrings(plist(body), 'A')).toEqual(['one']) + }) + it('returns [] when the key is absent or not an array', () => { + expect(plistArrayStrings(plist('Xs'), 'X')).toEqual([]) + expect(plistArrayStrings('', 'X')).toEqual([]) + }) +}) + +describe('ios-plist-read: plistDictBlock', () => { + it('returns the inner text of a one-level dict', () => { + const body = `NSAppTransportSecurityNSAllowsArbitraryLoads` + const inner = plistDictBlock(plist(body), 'NSAppTransportSecurity') + expect(inner).toContain('NSAllowsArbitraryLoads') + expect(inner).toContain('') + }) + it('returns null when the key is absent', () => { + expect(plistDictBlock(plist('Xs'), 'NSAppTransportSecurity')).toBeNull() + }) + it('does not throw on malformed input', () => { + expect(plistDictBlock('<<<', 'X')).toBeNull() + }) + it('returns the FULL balanced dict when a nested dict precedes a sibling key', () => { + // NSExceptionDomains (a nested dict) appears BEFORE NSAllowsArbitraryLoads. + // A lazy first-`` capture would truncate after NSExceptionDomains and + // hide NSAllowsArbitraryLoads; the balanced scan must keep the sibling. + const body = `NSAppTransportSecurity` + + `NSExceptionDomainsexample.comNSIncludesSubdomains` + + `NSAllowsArbitraryLoads` + + `` + const inner = plistDictBlock(plist(body), 'NSAppTransportSecurity') + expect(inner).not.toBeNull() + expect(inner).toContain('NSExceptionDomains') + expect(inner).toContain('NSAllowsArbitraryLoads') + // The matching close wins, so the trailing sibling bool is inside the block. + expect(plistBool(inner!, 'NSAllowsArbitraryLoads')).toBe(true) + }) + it('returns null on an unbalanced (never-closed) dict', () => { + expect(plistDictBlock(plist('NSAppTransportSecurityA'), 'NSAppTransportSecurity')).toBeNull() + }) +}) + +describe('ios-plist-read: grounding against the real-shaped Info.plist', () => { + const raw = REAL_SHAPED_INFO_PLIST + it('reads the literal CFBundleDisplayName', () => { + expect(plistString(raw, 'CFBundleDisplayName')).toBe('Tutorial Build example app') + }) + it('reads the $() build-var refs verbatim', () => { + expect(plistString(raw, 'CFBundleIdentifier')).toBe('$(PRODUCT_BUNDLE_IDENTIFIER)') + expect(plistString(raw, 'CFBundleShortVersionString')).toBe('$(MARKETING_VERSION)') + expect(plistString(raw, 'CFBundleVersion')).toBe('$(CURRENT_PROJECT_VERSION)') + expect(plistString(raw, 'CFBundleName')).toBe('$(PRODUCT_NAME)') + }) + it('reads LSRequiresIPhoneOS as true', () => { + expect(plistBool(raw, 'LSRequiresIPhoneOS')).toBe(true) + }) + it('reads UIRequiresFullScreen as null (key absent)', () => { + expect(plistBool(raw, 'UIRequiresFullScreen')).toBeNull() + }) + it('reads the ~ipad orientations (all four) separately from iPhone (three)', () => { + expect(plistArrayStrings(raw, 'UISupportedInterfaceOrientations~ipad')).toEqual([ + 'UIInterfaceOrientationPortrait', + 'UIInterfaceOrientationPortraitUpsideDown', + 'UIInterfaceOrientationLandscapeLeft', + 'UIInterfaceOrientationLandscapeRight', + ]) + expect(plistArrayStrings(raw, 'UISupportedInterfaceOrientations')).toEqual([ + 'UIInterfaceOrientationPortrait', + 'UIInterfaceOrientationLandscapeLeft', + 'UIInterfaceOrientationLandscapeRight', + ]) + }) +}) + +// Minimal pbxproj fixture: one project-level config pair + one app-target +// config pair, modelled on the Capacitor SPM layout. Release and Debug differ +// in PRODUCT_BUNDLE_IDENTIFIER so the Release-preferred lookup is observable. +const FIXTURE_PBX = `// !$*UTF8*$! +{ + objects = { +/* Begin PBXNativeTarget section */ + AAA1 /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */; + name = App; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ +/* Begin PBXProject section */ + PROJ /* Project object */ = { + isa = PBXProject; + attributes = { + TargetAttributes = { + AAA1 = { ProvisioningStyle = Automatic; }; + }; + }; + buildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */; + targets = ( + AAA1 /* App */, + ); + }; +/* End PBXProject section */ +/* Begin XCBuildConfiguration section */ + CFG_PROJ_DEBUG /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + SDKROOT = iphoneos; + }; + name = Debug; + }; + CFG_PROJ_RELEASE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + SDKROOT = iphoneos; + }; + name = Release; + }; + CFG_TGT_DEBUG /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.demo.app.debug; + PRODUCT_NAME = "Tutorial Build example app"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CFG_TGT_RELEASE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.demo.app; + PRODUCT_NAME = "Tutorial Build example app"; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ + LIST_PROJECT /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CFG_PROJ_DEBUG /* Debug */, + CFG_PROJ_RELEASE /* Release */, + ); + defaultConfigurationName = Release; + }; + LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CFG_TGT_DEBUG /* Debug */, + CFG_TGT_RELEASE /* Release */, + ); + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; +} +` + +describe('ios-pbxsettings: readBuildSetting (Release-preferred scalar)', () => { + it('prefers the Release value when configs disagree', () => { + expect(readBuildSetting(FIXTURE_PBX, 'PRODUCT_BUNDLE_IDENTIFIER')).toBe('com.demo.app') + }) + it('strips surrounding quotes from a quoted value', () => { + expect(readBuildSetting(FIXTURE_PBX, 'PRODUCT_NAME')).toBe('Tutorial Build example app') + expect(readBuildSetting(FIXTURE_PBX, 'TARGETED_DEVICE_FAMILY')).toBe('1,2') + }) + it('reads an unquoted scalar present in every config', () => { + expect(readBuildSetting(FIXTURE_PBX, 'IPHONEOS_DEPLOYMENT_TARGET')).toBe('15.0') + }) + it('returns null for an absent / inherited key', () => { + expect(readBuildSetting(FIXTURE_PBX, 'ENABLE_BITCODE')).toBeNull() + }) + it('does not throw on empty input', () => { + expect(readBuildSetting('', 'IPHONEOS_DEPLOYMENT_TARGET')).toBeNull() + }) +}) + +describe('ios-pbxsettings: resolvePlistValue ($()->pbxproj substitution)', () => { + it('substitutes a $(VAR) reference from the pbxproj', () => { + expect(resolvePlistValue('$(PRODUCT_BUNDLE_IDENTIFIER)', FIXTURE_PBX)).toBe('com.demo.app') + }) + it('substitutes the ${VAR} brace form too', () => { + expect(resolvePlistValue('${MARKETING_VERSION}', FIXTURE_PBX)).toBe('1.0') + }) + it('returns a literal value unchanged', () => { + expect(resolvePlistValue('1.4.2', FIXTURE_PBX)).toBe('1.4.2') + expect(resolvePlistValue('Tutorial Build example app', FIXTURE_PBX)).toBe('Tutorial Build example app') + }) + it('returns the raw $() string when the var has no pbxproj match (caller treats as skip)', () => { + expect(resolvePlistValue('$(CAPACITOR_DEBUG)', FIXTURE_PBX)).toBe('$(CAPACITOR_DEBUG)') + }) + it('does not substitute a value that merely contains a $() among other text', () => { + expect(resolvePlistValue('prefix-$(MARKETING_VERSION)', FIXTURE_PBX)).toBe('prefix-$(MARKETING_VERSION)') + }) +}) + +describe('ios-pbxsettings: readBuildConfigs', () => { + it('returns all four configs with project-level flagged', () => { + const configs = readBuildConfigs(FIXTURE_PBX) + expect(configs.length).toBe(4) + const projectLevel = configs.filter(c => c.isProjectLevel) + expect(projectLevel.length).toBe(2) + expect(projectLevel.every(c => c.settings.IPHONEOS_DEPLOYMENT_TARGET === '15.0')).toBe(true) + const targetLevel = configs.filter(c => !c.isProjectLevel) + expect(targetLevel.some(c => c.name === 'Release' && c.settings.PRODUCT_BUNDLE_IDENTIFIER === 'com.demo.app')).toBe(true) + }) + it('captures scalar settings only and strips quotes', () => { + const release = readBuildConfigs(FIXTURE_PBX).find(c => !c.isProjectLevel && c.name === 'Release')! + expect(release.settings.PRODUCT_NAME).toBe('Tutorial Build example app') + expect(release.settings.CODE_SIGN_STYLE).toBe('Automatic') + }) + it('returns [] on empty input', () => { + expect(readBuildConfigs('')).toEqual([]) + }) +}) + +describe('ios-pbxsettings: readTargetConfigs', () => { + it('returns per-target per-config settings for the signable App target', () => { + const targets = readTargetConfigs(FIXTURE_PBX) + expect(targets.length).toBe(1) + const app = targets[0] + expect(app.target.name).toBe('App') + const names = app.configs.map(c => c.name).sort() + expect(names).toEqual(['Debug', 'Release']) + const rel = app.configs.find(c => c.name === 'Release')! + expect(rel.settings.PRODUCT_BUNDLE_IDENTIFIER).toBe('com.demo.app') + const dbg = app.configs.find(c => c.name === 'Debug')! + expect(dbg.settings.PRODUCT_BUNDLE_IDENTIFIER).toBe('com.demo.app.debug') + }) + it('returns [] on empty input', () => { + expect(readTargetConfigs('')).toEqual([]) + }) +}) + +// Real-shaped pbxproj mirroring the tutorial-app's distinctive build settings +// (real bundle id, AppIcon name, version pair, Cap8 deployment target) so the +// grounding assertions below are self-contained and REAL on CI. +const REAL_SHAPED_PBX = `// !$*UTF8*$! +{ +\tobjects = { +/* Begin PBXNativeTarget section */ +\t\tAAA1 /* App */ = { +\t\t\tisa = PBXNativeTarget; +\t\t\tbuildConfigurationList = LIST_TARGET /* Build configuration list for PBXNativeTarget "App" */; +\t\t\tname = App; +\t\t\tproductType = "com.apple.product-type.application"; +\t\t}; +/* End PBXNativeTarget section */ +/* Begin PBXProject section */ +\t\tPROJ /* Project object */ = { +\t\t\tisa = PBXProject; +\t\t\tattributes = { +\t\t\t\tTargetAttributes = { +\t\t\t\t\tAAA1 = { ProvisioningStyle = Automatic; }; +\t\t\t\t}; +\t\t\t}; +\t\t\tbuildConfigurationList = LIST_PROJECT /* Build configuration list for PBXProject "App" */; +\t\t\ttargets = ( +\t\t\t\tAAA1 /* App */, +\t\t\t); +\t\t}; +/* End PBXProject section */ +/* Begin XCBuildConfiguration section */ +\t\tCFG_PROJ_DEBUG /* Debug */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t}; +\t\t\tname = Debug; +\t\t}; +\t\tCFG_PROJ_RELEASE /* Release */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 15.0; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t}; +\t\t\tname = Release; +\t\t}; +\t\tCFG_TGT_DEBUG /* Debug */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; +\t\t\t\tPRODUCT_NAME = "Tutorial Build example app"; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t}; +\t\t\tname = Debug; +\t\t}; +\t\tCFG_TGT_RELEASE /* Release */ = { +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = { +\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = app.capgo.plugin.TutorialBuild; +\t\t\t\tPRODUCT_NAME = "Tutorial Build example app"; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t}; +\t\t\tname = Release; +\t\t}; +/* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ +\t\tLIST_PROJECT /* Build configuration list for PBXProject "App" */ = { +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\tCFG_PROJ_DEBUG /* Debug */, +\t\t\t\tCFG_PROJ_RELEASE /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationName = Release; +\t\t}; +\t\tLIST_TARGET /* Build configuration list for PBXNativeTarget "App" */ = { +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\tCFG_TGT_DEBUG /* Debug */, +\t\t\t\tCFG_TGT_RELEASE /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationName = Release; +\t\t}; +/* End XCConfigurationList section */ +\t}; +} +` + +describe('ios-pbxsettings: grounding against the real-shaped pbxproj', () => { + const pbx = REAL_SHAPED_PBX + it('reads the grounding scalar build settings (Release-preferred)', () => { + expect(readBuildSetting(pbx, 'IPHONEOS_DEPLOYMENT_TARGET')).toBe('15.0') + expect(readBuildSetting(pbx, 'PRODUCT_BUNDLE_IDENTIFIER')).toBe('app.capgo.plugin.TutorialBuild') + expect(readBuildSetting(pbx, 'ASSETCATALOG_COMPILER_APPICON_NAME')).toBe('AppIcon') + expect(readBuildSetting(pbx, 'MARKETING_VERSION')).toBe('1.0') + expect(readBuildSetting(pbx, 'CURRENT_PROJECT_VERSION')).toBe('1') + expect(readBuildSetting(pbx, 'TARGETED_DEVICE_FAMILY')).toBe('1,2') + }) + it('returns null for ENABLE_BITCODE (absent in the grounding project)', () => { + expect(readBuildSetting(pbx, 'ENABLE_BITCODE')).toBeNull() + }) + it('resolves the real Info.plist $() refs against the real pbxproj', () => { + const info = REAL_SHAPED_INFO_PLIST + expect(resolvePlistValue(plistString(info, 'CFBundleIdentifier')!, pbx)).toBe('app.capgo.plugin.TutorialBuild') + expect(resolvePlistValue(plistString(info, 'CFBundleShortVersionString')!, pbx)).toBe('1.0') + expect(resolvePlistValue(plistString(info, 'CFBundleVersion')!, pbx)).toBe('1') + expect(resolvePlistValue(plistString(info, 'CFBundleName')!, pbx)).toBe('Tutorial Build example app') + }) + it('leaves CAPACITOR_DEBUG unresolved (no pbxproj setting -> skip)', () => { + const info = REAL_SHAPED_INFO_PLIST + expect(resolvePlistValue(plistString(info, 'CAPACITOR_DEBUG')!, pbx)).toBe('$(CAPACITOR_DEBUG)') + }) + it('finds the single signable App target with Debug+Release configs', () => { + const targets = readTargetConfigs(pbx) + expect(targets.length).toBe(1) + expect(targets[0].target.name).toBe('App') + expect(targets[0].configs.map(c => c.name).sort()).toEqual(['Debug', 'Release']) + }) +}) + +// A temp project dir populated with the real-shaped fixtures, standing in for +// the external tutorial-app checkout so the on-disk grounding tests (entitlements +// reader, appicon Contents.json) are self-contained and REAL on CI. +const REAL_PROJECT_DIR = makeProject({ + 'ios/App/App/App.entitlements': REAL_SHAPED_ENTITLEMENTS, + 'ios/App/App/Info.plist': REAL_SHAPED_INFO_PLIST, + 'ios/App/App.xcodeproj/project.pbxproj': REAL_SHAPED_PBX, + 'ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json': REAL_SHAPED_APPICON_CONTENTS, +}) + +const entitlements = (body: string) => ` + +${body}` + +describe('ios-entitlements: readAppEntitlements', () => { + it('reads ios/App/App/App.entitlements when present', () => { + const dir = makeProject({ 'ios/App/App/App.entitlements': entitlements('aps-environmentdevelopment') }) + const ent = readAppEntitlements(dir) + expect(ent).not.toBeNull() + expect(ent!.raw).toContain('aps-environment') + }) + it('returns null when the entitlements file is absent', () => { + expect(readAppEntitlements(makeProject({}))).toBeNull() + }) + it('reads the typed accessors off the raw text', () => { + const raw = entitlements(` + aps-environmentdevelopment + com.apple.security.application-groupsgroup.com.demo.app + com.apple.developer.healthkit`) + expect(entString(raw, 'aps-environment')).toBe('development') + expect(entArray(raw, 'com.apple.security.application-groups')).toEqual(['group.com.demo.app']) + expect(entBool(raw, 'com.apple.developer.healthkit')).toBe(true) + expect(entBool(raw, 'get-task-allow')).toBeNull() + expect(entArray(raw, 'keychain-access-groups')).toEqual([]) + }) + it('grounds clean against the real App.entitlements (aps-environment=development only)', () => { + const ent = readAppEntitlements(REAL_PROJECT_DIR) + expect(ent).not.toBeNull() + expect(entString(ent!.raw, 'aps-environment')).toBe('development') + expect(entArray(ent!.raw, 'com.apple.security.application-groups')).toEqual([]) + }) +}) + +describe('mobileprovision-parser: profileEntitlements', () => { + function detailFromXml(xml: string) { + const buf = Buffer.concat([Buffer.from([0x30, 0x82, 0x00, 0x00]), Buffer.from(xml, 'utf8')]) + return parseMobileprovisionDetailedFromBase64(buf.toString('base64')) + } + + it('parses string, array and bool capability keys (not application-identifier)', () => { + const xml = ` + +NameTest +UUIDu +TeamIdentifierTEAM123456 +Entitlements + application-identifierTEAM123456.com.demo.app + aps-environmentproduction + com.apple.security.application-groupsgroup.com.demo.appgroup.com.demo.shared + com.apple.developer.associated-domainsapplinks:demo.com + com.apple.developer.healthkit + get-task-allow + +` + const ent = detailFromXml(xml).profileEntitlements + expect(ent['aps-environment']).toBe('production') + expect(ent['com.apple.security.application-groups']).toEqual(['group.com.demo.app', 'group.com.demo.shared']) + expect(ent['com.apple.developer.associated-domains']).toEqual(['applinks:demo.com']) + expect(ent['com.apple.developer.healthkit']).toBe(true) + expect(ent['get-task-allow']).toBe(false) + // application-identifier is auto-managed and intentionally NOT surfaced. + expect('application-identifier' in ent).toBe(false) + }) + + it('surfaces capability keys outside the legacy allowlist (App Attest, Sign in with Apple, Siri)', () => { + // Regression: the profile side must scan ALL entitlement keys generically, the + // same way the app side does. Previously only a fixed ~10-key allowlist was read, + // so any granted-but-non-allowlisted capability looked "missing" and produced a + // false-positive blocking error in entitlements-vs-profile-capability. + const xml = ` + +NameTest +UUIDu +TeamIdentifierTEAM123456 +Entitlements + application-identifierTEAM123456.com.demo.app + com.apple.developer.devicecheck.appattest-environmentproduction + com.apple.developer.siri + com.apple.developer.applesigninDefault + +` + const ent = detailFromXml(xml).profileEntitlements + expect(ent['com.apple.developer.devicecheck.appattest-environment']).toBe('production') + expect(ent['com.apple.developer.siri']).toBe(true) + expect(ent['com.apple.developer.applesignin']).toEqual(['Default']) + // auto-managed keys stay hidden even with generic scanning. + expect('application-identifier' in ent).toBe(false) + }) + + it('omits capability keys that are absent (missing != false)', () => { + const ent = detailFromXml(makeProfileXml({ type: 'app_store' })).profileEntitlements + expect('com.apple.security.application-groups' in ent).toBe(false) + expect('aps-environment' in ent).toBe(false) + expect('get-task-allow' in ent).toBe(false) + }) + + it('returns {} when there is no Entitlements dict (never throws)', () => { + const xml = ` + +NameTest +UUIDu +` + expect(detailFromXml(xml).profileEntitlements).toEqual({}) + }) + + it('keeps the existing detail fields intact (no regression)', () => { + const detail = detailFromXml(makeProfileXml({ type: 'app_store', teamId: 'TEAM999999', bundleId: 'com.demo.app' })) + expect(detail.teamId).toBe('TEAM999999') + expect(detail.bundleId).toBe('com.demo.app') + expect(detail.profileType).toBe('app_store') + expect(detail.profileEntitlements).toEqual({}) + }) +}) + +const APPICON_REL = 'ios/App/App/Assets.xcassets/AppIcon.appiconset' +const CONTENTS_REL = `${APPICON_REL}/Contents.json` + +describe('ios-appicon: readContentsJson', () => { + it('parses a valid Contents.json', () => { + const dir = makeProject({ [CONTENTS_REL]: JSON.stringify({ images: [{ size: '1024x1024', filename: 'icon.png' }] }) }) + const c = readContentsJson(join(dir, CONTENTS_REL)) + expect(c?.images?.length).toBe(1) + expect(c?.images?.[0].filename).toBe('icon.png') + }) + it('returns null on a missing file (never throws)', () => { + expect(readContentsJson('/no/such/Contents.json')).toBeNull() + }) + it('returns null on malformed JSON (never throws)', () => { + const dir = makeProject({ [CONTENTS_REL]: '{ not: json,,, ' }) + expect(readContentsJson(join(dir, CONTENTS_REL))).toBeNull() + }) +}) + +describe('ios-appicon: appIconSetDir', () => { + it('defaults to AppIcon when no pbxproj icon name is given', () => { + const dir = makeProject({}) + expect(appIconSetDir(dir)).toBe(join(dir, 'ios', 'App', 'App', 'Assets.xcassets', 'AppIcon.appiconset')) + }) + it('honours ASSETCATALOG_COMPILER_APPICON_NAME from the pbxproj', () => { + const dir = makeProject({}) + const pbx = [ + 'CFG /* Release */ = {', + '\tisa = XCBuildConfiguration;', + '\tbuildSettings = {', + '\t\tASSETCATALOG_COMPILER_APPICON_NAME = BrandIcon;', + '\t};', + '\tname = Release;', + '};', + ].join('\n') + expect(appIconSetDir(dir, pbx)).toBe(join(dir, 'ios', 'App', 'App', 'Assets.xcassets', 'BrandIcon.appiconset')) + }) +}) + +describe('ios-appicon: hasMarketingIcon', () => { + it('true when a 1024x1024 image is present', () => { + expect(hasMarketingIcon({ images: [{ size: '1024x1024', filename: 'icon.png' }] })).toBe(true) + }) + it('trims whitespace around the size before comparing', () => { + expect(hasMarketingIcon({ images: [{ size: ' 1024x1024 ', filename: 'icon.png' }] })).toBe(true) + }) + it('true when an image has role marketing', () => { + expect(hasMarketingIcon({ images: [{ role: 'marketing', filename: 'icon.png' }] })).toBe(true) + }) + it('false when only smaller icons are present', () => { + expect(hasMarketingIcon({ images: [{ size: '60x60', scale: '2x', filename: 'i.png' }] })).toBe(false) + }) + it('false on null/empty contents (never throws)', () => { + expect(hasMarketingIcon(null)).toBe(false) + expect(hasMarketingIcon({})).toBe(false) + expect(hasMarketingIcon({ images: [] })).toBe(false) + }) +}) + +describe('ios-appicon: grounding against the real AppIcon.appiconset', () => { + const realContents = join(REAL_PROJECT_DIR, CONTENTS_REL) + it('parses the real single-universal-1024 Contents.json', () => { + const c = readContentsJson(realContents) + expect(c?.images?.length).toBe(1) + expect(c?.images?.[0].size).toBe('1024x1024') + expect(c?.images?.[0].filename).toBe('AppIcon-512@2x.png') + }) + it('has the marketing (1024) icon', () => { + expect(hasMarketingIcon(readContentsJson(realContents))).toBe(true) + }) + it('resolves the real appicon dir from the pbxproj AppIcon name', () => { + const pbx = REAL_SHAPED_PBX + expect(appIconSetDir(REAL_PROJECT_DIR, pbx)).toBe(join(REAL_PROJECT_DIR, 'ios', 'App', 'App', 'Assets.xcassets', 'AppIcon.appiconset')) + }) +}) diff --git a/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md b/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md new file mode 100644 index 0000000000..8fa0ae44c0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-prescan-ios-expansion-design.md @@ -0,0 +1,414 @@ +# Prescan iOS Expansion — Implementation-Ready Design + +Status: design (no code in this phase) +Branch: `wolny/prescan-ios-expansion` +Date: 2026-06-22 +Author: lead designer (merge of 4 research outputs) + +## 0. Scope & ground truth + +This spec merges four iOS prescan research outputs (Info.plist/App Store, Xcode +project/build settings, Entitlements/Capacitor config, Pods/SPM/App icons) into +ONE deduplicated, TDD-ready check pack. It is grounded against a real Capacitor 8 +SPM project at +`/Users/michaltremblay/Developer/capgo-saas/capgo_builder/tutorial-app/ios`. + +### Verified ground-truth facts (drive the FP guards) + +- Info.plist stores **build-variable references**, not literals: + `CFBundleIdentifier=$(PRODUCT_BUNDLE_IDENTIFIER)`, `CFBundleShortVersionString=$(MARKETING_VERSION)`, + `CFBundleVersion=$(CURRENT_PROJECT_VERSION)`, `CFBundleName=$(PRODUCT_NAME)`. + CFBundleDisplayName is a **literal** (`Tutorial Build example app`). + → `resolvePlistValue()` substitution is the single biggest false-positive guard. +- `LSRequiresIPhoneOS=`, `UILaunchStoryboardName=LaunchScreen` present. +- `UISupportedInterfaceOrientations` (iPhone) is MISSING `PortraitUpsideDown`; + `UISupportedInterfaceOrientations~ipad` has all four. → multitasking check MUST + read the `~ipad` array only. +- `App.entitlements` = `{ aps-environment: development }` only. → the universal + Capacitor default leftover; on this push-free app the aps-vs-mode check surfaces it + as a **warning** (not a build-blocking error) under app_store, so the grounding + project still scans clean of errors; no app-groups/iCloud/associated-domains. +- pbxproj target (Debug+Release identical): `CODE_SIGN_STYLE=Automatic`, + `DEVELOPMENT_TEAM=UVTJ336J2D`, `IPHONEOS_DEPLOYMENT_TARGET=15.0`, + `SWIFT_VERSION=5.0`, `TARGETED_DEVICE_FAMILY="1,2"` (iPad-capable), + `PRODUCT_BUNDLE_IDENTIFIER=app.capgo.plugin.TutorialBuild` (same in both configs), + `ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon`, `MARKETING_VERSION=1.0`, + `CURRENT_PROJECT_VERSION=1`. `ENABLE_BITCODE` ABSENT. Project-level config blocks + only carry `IPHONEOS_DEPLOYMENT_TARGET`. +- SPM-only layout: NO `Podfile`, NO `Pods/`, NO `App.xcworkspace`. + `Package.swift` = `platforms: [.iOS(.v15)]`, depends on `capacitor-swift-pm` exact `8.3.1`, + `.product(name: "Capacitor", ...)` + `.product(name: "Cordova", ...)`. + `Package.resolved` at `ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`. +- `AppIcon.appiconset/Contents.json`: single universal `1024x1024` entry, + `filename=AppIcon-512@2x.png`, file present. + +**Every check below produces NO finding against this healthy project** (the +acceptance baseline for TDD: the grounding project must scan clean). + +### Engine facts that constrain check authoring + +- `Finding.id` is the check id; existing checks reuse the same id across all + findings they emit (mirror this — do NOT invent per-finding ids). +- Each `run()` is crash- and timeout-isolated (10s) and degrades to an `info` + notice; `appliesTo` is crash-isolated too. So a `run()` that throws is safe, + but checks should still early-return `[]` on missing files for clean output. +- `Finding.detail/title/fix` are printed and serialized to `--json` (CI logs): + NEVER embed credential material. All checks here read only project files, so + this is naturally satisfied (the one credential-adjacent input is the + provisioning map, and we only surface entitlement KEY names + bundle ids). +- `ctx.config` is a Capacitor config with `.passthrough()` — `ctx.config?.server?.url` + / `.cleartext` / `.allowNavigation` are reachable with safe optional access; no + parser needed. +- `willUploadToAppStore(ctx)` already exists (upload-intent.ts): iOS + mode + `app_store` (default when undefined) + full ASC triplet + (`APPLE_KEY_ID`+`APPLE_ISSUER_ID`+`APPLE_KEY_CONTENT`). +- `parseProvisioningMap(ctx)` + `hasMap` live in ios-profiles.ts; `bundleMatches()` + (wildcard-aware) is local to that file — extract/share for entitlement subset checks. + +--- + +## 1. Parsing infrastructure (new files + reuse) + +NO new npm deps. NO fast-xml-parser (deliberately removed). Targeted regex for +plist/pbxproj/Podfile/Package.swift (XML/pbx text), `JSON.parse` for Contents.json +and package.json (already JSON). All readers use `existsSync`/`readFileSync` +(node:fs) + `join` (node:path) and return null/[]/false on absence — never throw. + +### 1.1 `cli/src/build/prescan/checks/ios-plist-read.ts` (NEW — shared plist value reader) + +Promotes the private `plistStringValue` from ios-plist.ts and adds the value-typed +readers the new checks need. Keep `SCHEME_RE` where it is (ios-plist.ts) or re-export. + +```ts +plistString(raw, key): string | null // top-level KV (= existing plistStringValue) +plistBool(raw, key): boolean | null // K\s*<(true|false)/> ; null when key absent +plistHasKey(raw, key): boolean // raw.includes(`${key}`) (presence only) +plistArrayStrings(raw, key): string[] // K\s*... -> its children +plistDictBlock(raw, key): string | null // inner text of K\s*..., ONE level non-greedy +``` + +- `plistArrayStrings` generalizes the existing CFBundleURLSchemes block+children + pattern (ios-plist.ts L55-58). Needed for orientations + UIBackgroundModes. +- `plistDictBlock` matches `extractNestedPlistValue`'s dict-capture regex + (mobileprovision-parser.ts L198). ONE-level nesting only — document the limit + like `resolveBundleId`. Needed to scope NSAppTransportSecurity reads. +- Regex notes: build all `key` regexes with the existing `escapeRegex` pattern; + arrays/dicts use `[\s\S]*?` (non-greedy) so the first closing tag wins. + +### 1.2 `cli/src/build/prescan/ios-buildsettings.ts` (NEW — pbxproj scalar + plist-var resolver) + +Two cross-file resolvers. Implemented standalone here (rather than mutating +pbxproj-parser) to keep the existing signing pipeline untouched; both reuse +`readPbxproj` from pbxproj-parser.ts. + +```ts +readBuildSetting(pbxContent, name): string | null +// Release-preferred scalar lookup, mirroring resolveBundleId's Release-vs-fallback walk. +// Generalize pbxproj-parser L82-103: per build-config block, match `NAME = "?VALUE"?;` +// (strip quotes). Return the Release value if any config is named Release; else the +// first non-null value found (fallback). SCALAR keys only — never array-valued settings. + +resolvePlistValue(rawValue, pbxContent): string +// If rawValue is exactly one $(VAR) or ${VAR} reference, substitute via readBuildSetting(VAR). +// Else return rawValue unchanged. EVERY value-format check pipes the plist value through +// this before validating. If the var has no pbxproj match, return the raw $() string +// (callers treat a still-unresolved $() as "skip / cannot judge"). +``` + +### 1.3 `cli/src/build/prescan/ios-pbxsettings.ts` helper OR extend ios-buildsettings (NEW — per-config + per-target) + +For signing / per-config / swift checks we need ALL configs, not just Release-preferred: + +```ts +interface BuildConfig { name: string; settings: Record; isProjectLevel: boolean } +readBuildConfigs(pbxContent): BuildConfig[] +// Generalize the buildConfig block walk (pbxproj-parser L82-96): for every +// XCBuildConfiguration block capture name (L95 regex) + all `KEY = "?VALUE"?;` scalar pairs. +// isProjectLevel = referenced by the PBXProject's buildConfigurationList (find the +// `isa = PBXProject` block, read its buildConfigurationList id, mark those configs). + +interface TargetConfigs { target: PbxTarget; configs: { name: string; settings: Record }[] } +readTargetConfigs(pbxContent): TargetConfigs[] +// For each signable target (reuse findSignableTargets + its configList walk), capture +// the FULL settings dict per config (Debug/Release) in the same pass resolveBundleId uses. +``` + +CRITICAL parser caveat (drives "skip absent key" rule everywhere): the one-level +nested-brace cap means **array-valued settings** (`LD_RUNPATH_SEARCH_PATHS = ( ... )`, +`GCC_PREPROCESSOR_DEFINITIONS`) may be captured raw/truncated. Checks MUST only rely +on **scalar** keys (`IPHONEOS_DEPLOYMENT_TARGET`, `CODE_SIGN_STYLE`, `DEVELOPMENT_TEAM`, +`SWIFT_VERSION`, `ENABLE_BITCODE`, `PRODUCT_BUNDLE_IDENTIFIER`, `TARGETED_DEVICE_FAMILY`, +`ASSETCATALOG_COMPILER_APPICON_NAME`, `MARKETING_VERSION`, `CURRENT_PROJECT_VERSION`, +`PRODUCT_NAME`). Build-setting inheritance/xcconfig is NOT resolved: an ABSENT scalar +key == "unknown/inherited" → SKIP (no finding). Only flag a PRESENT bad value. + +### 1.4 `cli/src/build/prescan/ios-entitlements.ts` (NEW — app + profile entitlements) + +```ts +// App's own entitlements file. Capacitor convention: ios/App/App/App.entitlements +// (future enhancement: resolve via pbxproj CODE_SIGN_ENTITLEMENTS; default path is reliable). +readAppEntitlements(projectDir): { raw: string } | null // null when file absent +entString(raw, key): string | null // reuse plistString +entArray(raw, key): string[] // reuse plistArrayStrings +entBool(raw, key): boolean | null // reuse plistBool +``` + +Profile entitlements: extend `MobileprovisionDetail` (mobileprovision-parser.ts) with a +`profileEntitlements` accessor. Reuse the existing `extractNestedPlistValue` dict-capture +for the `Entitlements` block, plus an array-aware sibling (the +DeveloperCertificates array regex L167 is the template) to read these keys from inside +that dict: `aps-environment` (string), `com.apple.security.application-groups` (array), +`com.apple.developer.associated-domains` (array), `com.apple.developer.icloud-container-identifiers` +(array), `com.apple.developer.icloud-services` (array), `com.apple.developer.ubiquity-kvstore-identifier` +(string), `com.apple.developer.healthkit` (bool), `keychain-access-groups` (array), +`com.apple.developer.in-app-payments` (array), `com.apple.developer.networking.*` (array/bool), +`get-task-allow` (bool). ~30-50 lines, no new deps. Expose as a structured getter so +checks read `profileEntitlements[key]` rather than re-parsing. + +### 1.5 `cli/src/build/prescan/ios-appicon.ts` (NEW — AppIcon Contents.json reader) + +```ts +interface AppIconImage { idiom?: string; size?: string; scale?: string; filename?: string; platform?: string; role?: string } +interface AssetContents { images?: AppIconImage[]; info?: { version?: number; author?: string } } +readContentsJson(path): AssetContents | null // existsSync + readFileSync + JSON.parse in try/catch; null on missing/parse error (NEVER throws) +appIconSetDir(projectDir, pbxContent?): string // join(projectDir,'ios','App','App','Assets.xcassets', `${iconName}.appiconset`) + // iconName = readBuildSetting(pbx,'ASSETCATALOG_COMPILER_APPICON_NAME') ?? 'AppIcon' +hasMarketingIcon(c): boolean // images.some(i => normalizeSize(i.size)==='1024x1024' || i.role==='marketing'); normalizeSize trims whitespace +``` + +### 1.6 `cli/src/build/prescan/capacitor-version.ts` (NEW — extract shared `capacitorMajor`) + +Move the private `capacitorMajor(projectDir)` out of android-project.ts (L539-556) into +this shared module so the iOS deployment-target check reuses the exact +`@capacitor/core` ?? `@capacitor/ios` major detection. (Android currently reads +`@capacitor/core` ?? `@capacitor/android`; generalize to check core, ios, android.) +Re-import it back into android-project.ts to avoid duplication. + +### 1.7 Reuse (no change) + +`readTextIfExists` (gradle.ts L5) for Podfile/Podfile.lock/Package.swift/Package.resolved/ +package.json text. `existsSync`/`readdirSync` for Pods/ & workspace presence. +`findSignableTargets`/`readPbxproj` (pbxproj-parser.ts). `capacitorPluginDeps` heuristic +(shared.ts L8-21) for optional plugin-pod cross-check. `parseProvisioningMap`/`hasMap`/ +`bundleMatches` (ios-profiles.ts — export `bundleMatches`). + +--- + +## 2. Final NEW iOS checks + +Local = reads only project files. None of the new checks are `remote`. `appliesTo` +predicates are exact. Severity given as `error`/`warning`; "error-on-upload" means +`willUploadToAppStore(ctx) ? error : warning`. + +### 2.A Info.plist / App Store (file: `checks/ios-plist-store.ts`) + +| id | sev | local | appliesTo (exact) | detection (algorithm/regex) | fix | +|----|-----|-------|-------------------|------------------------------|-----| +| `ios/plist-bundle-id-format` | error | local | iOS; Info.plist exists | `v=plistString(raw,'CFBundleIdentifier')`. null→error "missing". `r=resolvePlistValue(v,pbx)`. If `r` still starts `'$('`→skip. Validate `r` against `/^[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/` (reverse-DNS ≥2 segments, no space/_/`*`). Fail→error naming `r`. | Set valid reverse-DNS PRODUCT_BUNDLE_IDENTIFIER (no spaces/underscores/wildcards). | +| `ios/plist-version-short-format` | error | local | iOS; Info.plist exists | `v=resolvePlistValue(plistString(raw,'CFBundleShortVersionString'),pbx)`. null→(SKIP — presence owned by infoplist-sanity, see §5). `'$('`→skip. Else validate `/^\d+(\.\d+){0,2}$/`. Fail→error with value (ITMS-90060). | Set MARKETING_VERSION to ≤3 dot-separated integers (e.g. 1.4.2). | +| `ios/plist-version-build-format` | error | local | iOS; Info.plist exists | `v=resolvePlistValue(plistString(raw,'CFBundleVersion'),pbx)`. null→SKIP. `'$('`→skip. Else `/^\d+(\.\d+){0,2}$/`. Fail→error. Do NOT require monotonic/ordering (needs ASC history). | Set CURRENT_PROJECT_VERSION numeric, ≤3 integers (e.g. 42 or 1.4.42). | +| `ios/plist-encryption-compliance` | warning | local | `willUploadToAppStore(ctx)` | `plistHasKey(raw,'ITSAppUsesNonExemptEncryption')===false` → warning. Do NOT assert which value is correct. | Add `ITSAppUsesNonExemptEncryption=` (most Capacitor apps) to stop the per-upload Missing Compliance prompt. | +| `ios/plist-ats-arbitrary-loads` | warning (→error on upload+dev-config) | local | iOS; Info.plist exists | `d=plistDictBlock(raw,'NSAppTransportSecurity')`; null→[]. If `plistBool(d,'NSAllowsArbitraryLoads')===true`→finding. Escalate to **error** when `willUploadToAppStore(ctx) && (ctx.config?.server?.cleartext===true || ctx.config?.server?.url)`. | Remove NSAllowsArbitraryLoads (or ``); use scoped NSExceptionDomains; remove server.url/cleartext before release. | +| `ios/plist-launch-storyboard` | error | local | iOS; Info.plist exists | `ok = plistHasKey(raw,'UILaunchStoryboardName') || plistHasKey(raw,'UILaunchScreen')`. !ok→error (ITMS-90475/90096). (Drop the optional storyboard-file-existence sub-check — higher FP, low value.) | Add `UILaunchStoryboardName=LaunchScreen` (Capacitor default) or a UILaunchScreen dict. | +| `ios/plist-orientations-multitasking` | warning | local | iOS; Info.plist exists; `TARGETED_DEVICE_FAMILY` (readBuildSetting) contains `2`; NOT `plistBool(raw,'UIRequiresFullScreen')===true` | `ipad = plistArrayStrings(raw,'UISupportedInterfaceOrientations~ipad')` (fallback to non-suffixed only if `~ipad` key entirely absent). Missing any of the four `UIInterfaceOrientation{Portrait,PortraitUpsideDown,LandscapeLeft,LandscapeRight}`→warning listing missing. SCOPE STRICTLY to `~ipad` (grounding iPhone array is missing PortraitUpsideDown → would FP). (ITMS-90474). | Add all four to `~ipad`, or add `UIRequiresFullScreen=`. | +| `ios/plist-orientations-present` | warning | local | iOS; Info.plist exists | `arr=plistArrayStrings(raw,'UISupportedInterfaceOrientations')`. Key absent OR zero entries→warning. Present-but-invalid token (not one of the four constants)→warning naming the bad token. | Declare ≥1 valid UIInterfaceOrientation* value. | +| `ios/plist-display-name` | warning | local | iOS; Info.plist exists | `disp=resolvePlistValue(plistString(raw,'CFBundleDisplayName'),pbx)`; `name=resolvePlistValue(plistString(raw,'CFBundleName'),pbx)`. Warning only if BOTH null/empty after resolution OR both still `'$('` with no pbx match. Grounding passes (literal CFBundleDisplayName). | Set CFBundleDisplayName or ensure PRODUCT_NAME resolves. | +| `ios/plist-background-modes-sanity` | warning | local | iOS; Info.plist has `UIBackgroundModes` | `modes=plistArrayStrings(raw,'UIBackgroundModes')`; empty→[]. (a) any token ∉ {audio,location,voip,fetch,remote-notification,processing,bluetooth-central,bluetooth-peripheral,external-accessory,newsstand-content}→warning (invalid mode). (b) `location` present but no `NSLocation*UsageDescription` key in plist→warning (2.5.4). Gate (b) to `willUploadToAppStore(ctx)`. Do NOT hard-error on audio/location presence. | Remove unused background modes; add matching usage strings/capabilities. | +| `ios/plist-iphoneos-required` | warning | local | iOS; Info.plist exists | `b=plistBool(raw,'LSRequiresIPhoneOS')`. Key absent→warning "missing". `b===false`→warning. Grounding ``→pass. | Add `LSRequiresIPhoneOS=`. | + +Notes: +- `ios/plist-deployment-target` from research #1 is CUT (see §5): no real ASC + deployment-target floor exists; the build-breaking floor is Capacitor's and is + covered by `ios/xcode-deployment-target-capacitor` in §2.B. +- `ios/plist-bundle-id-vs-appid` from research #1 is CUT/folded (see §5): duplicates + `shared/bundle-id-consistency`. +- `ios/plist-app-icon` from research #1 is CUT: superseded by the §2.F appicon checks + (asset-catalog based, cleaner split). + +### 2.B Xcode project / build settings (file: `checks/ios-xcode.ts`) + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/xcode-deployment-target-capacitor` | error | local | iOS; `capacitorMajor(projectDir)!==null`; `IPHONEOS_DEPLOYMENT_TARGET` PRESENT in app-target or project-level config | floor map `{5:13,6:13,7:14,8:14, default:14}` (extend android-style). Read target value via `readBuildSetting`/`readTargetConfigs` app (application product-type) target Release-pref + project-level. `parseFloat`. Flag PRESENT value `< floor`. Absent key OR null major→[]. Grounding 15.0/Cap8(floor14)→pass. | Raise IPHONEOS_DEPLOYMENT_TARGET to ≥floor (project + app target) and the Podfile platform line if present. | +| `ios/xcode-signing-team` | error-on-upload | local | iOS; some signable target has `CODE_SIGN_STYLE` PRESENT (Automatic or Manual) with `DEVELOPMENT_TEAM` absent/empty; AND `parseProvisioningMap(ctx).length===0` | **MERGED** Automatic+Manual no-team checks (research recommended). Per signable target Release-pref (fallback Debug): if CODE_SIGN_STYLE present (Automatic or Manual) AND DEVELOPMENT_TEAM absent/empty/`""`→finding. Suppress entirely when a provisioning map IS present (builder injects team+manual signing). Severity `error` when `willUploadToAppStore(ctx)` else `warning`. Grounding DEVELOPMENT_TEAM=UVTJ336J2D→pass. | Set DEVELOPMENT_TEAM in Xcode, or supply signing creds/profiles to the cloud build (map switches to managed-manual). | +| `ios/xcode-bundle-id-mismatch-across-configs` | warning | local | iOS; ≥1 signable target has `PRODUCT_BUNDLE_IDENTIFIER` PRESENT in ≥2 configs | Per signable target collect PRODUCT_BUNDLE_IDENTIFIER from every config in its configList (`readTargetConfigs`). If ≥2 PRESENT values differ→warning naming target + per-config values. Ignore absent (inherited). Single-config→skip. Grounding Debug==Release→pass. Distinct from shared/bundle-id-consistency (appId vs resolved) and targets-covered (profile coverage). | Align PRODUCT_BUNDLE_IDENTIFIER across Debug/Release (or ignore if Debug suffix is intentional). | +| `ios/xcode-enable-bitcode-leftover` | warning | local | iOS; `ENABLE_BITCODE` PRESENT with value `YES` anywhere (`readBuildConfigs`) | Read ENABLE_BITCODE from project + target configs. Flag any PRESENT `==YES` (aggregate which configs). Absent (modern default)→[]. Grounding absent→pass. | Set ENABLE_BITCODE=NO or delete it (deprecated since Xcode 14). | +| `ios/xcode-swift-version-sanity` | warning | local | iOS; signable target has `SWIFT_VERSION` PRESENT | Per signable target parse leading numeric of SWIFT_VERSION. Flag PRESENT and (`<5` OR not a number). Absent→skip (Obj-C-only target). Grounding 5.0→pass. | Set SWIFT_VERSION=5.0 (or intended ≥5). | +| `ios/xcode-no-app-target` | error | local | iOS; `readPbxproj(projectDir)!==null` | `findSignableTargets`; count `productType==='com.apple.product-type.application'`. If pbxproj non-null but count===0→error. readPbxproj null→skip (project-missing owned elsewhere). Grounding 1 app target→pass. | `npx cap sync ios` or restore the application target. | +| `ios/xcode-multiple-app-targets` | warning | local | iOS; `findSignableTargets` yields >1 application-product-type target | Filter `productType==='com.apple.product-type.application'`; `length>1`→warning listing names+bundle ids. Extensions don't count. Grounding 1→pass. | Keep a single app target; remove the duplicate or pass the intended scheme. | + +Notes: +- `ios/xcode-automatic-signing-no-team` + `ios/xcode-manual-signing-no-team` + (research #2) MERGED into `ios/xcode-signing-team`. + +### 2.C Entitlements / capabilities (file: `checks/ios-entitlements-checks.ts`) + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/entitlements-vs-profile-capability` | error | local | `hasMap(ctx)` AND `readAppEntitlements(projectDir)!==null` | For each mapped profile parse `profileEntitlements` (parsed GENERICALLY — every key in the profile Entitlements dict, by sibling value tag string/bool/array; the old fixed ~10-key allowlist was an app-vs-profile asymmetry that false-positived granted-but-non-allowlisted capabilities like App Attest / Sign in with Apple / Siri) + app entitlements. Build app capability-key set EXCLUDING `aps-environment` (own check), `get-task-allow`, `application-identifier`, `*.team-identifier` (auto-managed). For each app key, profile must grant it: bool/string→same key present; array keys (application-groups, associated-domains, keychain-access-groups, icloud-container-identifiers) → every app member ⊆ profile list, BUT a profile wildcard member (`*`, `$(VAR)*`, OR the RESOLVED-team form `.*`) covers all. App members carry the `$(AppIdentifierPrefix)`/`$(TeamIdentifierPrefix)` variable while the profile carries the resolved 10-char team prefix — both are stripped before the subset compare so suffixes match. One error per missing/under-covered key naming key + bundle id. (Covers iCloud — see §5, iCloud-specific check folded in.) | Enable the capability for this App ID in the portal, regenerate the profile, re-save creds; or remove the unused entitlement. | +| `ios/entitlements-aps-environment-vs-mode` | warning/**error** | local | `readAppEntitlements` has `aps-environment` AND `ctx.distributionMode` set | `v=entString(raw,'aps-environment')`. `distributionMode==='app_store' && v==='development'`→**warning** by default (the default Capacitor leftover is benign on a push-free app and the cloud builder neither rewrites entitlements nor fails the archive on it), escalating to **error** only with independent push evidence (Info.plist `UIBackgroundModes` contains `remote-notification`). `distributionMode==='ad_hoc' && v==='production'`→**warning** (ad_hoc may use prod APNs). If `hasMap`: cross-check profile `aps-environment`; app≠profile→**error** (a mapped profile granting `production` while the app declares `development` is a real signing mismatch). Grounding (development, no mode→appliesTo false unless mode set; with app_store→**warning**, NOT a build-blocking error — restores the clean-scan baseline). | Set aps-environment=production for App Store/TestFlight push, or remove aps-environment if the app does not use push. | +| `ios/entitlements-associated-domains-format` | warning | local | `readAppEntitlements` has non-empty `com.apple.developer.associated-domains` | Per ``: require `/^(applinks|webcredentials|activitycontinuation|appclips):[a-z0-9.-]+(\?mode=(developer|managed))?$/i`. Flag entries containing `://`, starting `http`, containing `/`, containing spaces, or unknown service prefix. Don't flag the `service:*` managed-wildcard form. | Use `service:domain` (e.g. `applinks:example.com`) — no scheme/path/trailing slash. | +| `ios/entitlements-app-groups-format` | warning | local | `readAppEntitlements` has non-empty `com.apple.security.application-groups` | Per ``: warn if it does not start with `group.`, or contains uppercase/whitespace/illegal chars. Pure format (subset coverage handled by entitlements-vs-profile). | Rename to `group.` and register in the portal. | + +### 2.D Capacitor config (file: `checks/ios-capacitor-config.ts`) + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/capacitor-server-url-shipped` | error (→warning if not uploading) | local | iOS; `typeof ctx.config?.server?.url === 'string' && server.url !== ''` | Non-empty server.url→finding. Detail escalates when dev target: `http://` (non-https), RFC1918 IP (`10.`/`172.(1[6-9]|2\d|3[01]).`/`192.168.`), `localhost`/`127.0.0.1`, or tunnel host (`*.ngrok.io`/`*.trycloudflare.com`/`*.loca.lt`). Severity `error` when `willUploadToAppStore(ctx)` else `warning` (research note: optionally always-error; we gate hard error to upload to limit dev-scan noise). | Remove server.url live-reload block before production; build web assets + `npx cap sync`. | +| `ios/capacitor-server-cleartext` | warning (→error w/ http url) | local | iOS; `ctx.config?.server?.cleartext === true` | `cleartext===true`→warning. Escalate to **error** when `server.url` present AND starts `http://`. | Remove/`false` server.cleartext for production; use https or a scoped ATS exception. | +| `ios/capacitor-allow-navigation-wildcard` | warning | local | iOS; `Array.isArray(ctx.config?.server?.allowNavigation)` and it contains a wildcard-only/public-suffix-wildcard entry | For each entry flag `*` and `*.` (e.g. `*.com`,`*.io`) with no specific host. Do NOT flag `*.example.com` or concrete hosts. One finding listing offenders. | Restrict allowNavigation to specific hosts; remove blanket `*`. | + +### 2.E Pods / SPM (file: `checks/ios-deps.ts`) + +Layout discriminator up front: CocoaPods = `readTextIfExists(ios/App/Podfile)!==null`; +SPM = `readTextIfExists(ios/App/CapApp-SPM/Package.swift)!==null`. Pods checks +early-return when no Podfile; SPM checks early-return when no Package.swift. +Grounding is SPM-only → all Pods checks early-return. + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/pods-not-installed` | error | local | iOS; `ios/App/Podfile` exists | `podsDir=existsSync(ios/App/Pods)`; `ws=existsSync(ios/App/App.xcworkspace)`. `!podsDir || !ws`→error (distinguish the two sub-cases in detail). Do NOT fire when Podfile absent (SPM; cap-sync-stale owns missing-Podfile). | `npx cap sync ios` (or `pod install`); commit Pods/ + App.xcworkspace. | +| `ios/pods-lock-missing` | warning | local | iOS; `ios/App/Podfile` exists | `readTextIfExists(ios/App/Podfile.lock)===null`→warning. Separate from pods-not-installed. | `pod install`; commit Podfile.lock. | +| `ios/pods-capacitor-missing` | error | local | iOS; `ios/App/Podfile` exists | Podfile text NOT matching `/pod\s+['"]Capacitor['"]/`→error (core not wired). Optional lower-confidence detail: plugins from `capacitorPluginDeps` whose PascalCase pod (`@capacitor/camera`→`CapacitorCamera`) is absent from the Podfile — keep as DETAIL only (community plugins have non-standard pod names). | `npx cap sync ios` then `pod install`. | +| `ios/spm-package-resolved-missing` | error | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists | Neither `ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` NOR `ios/App/CapApp-SPM/Package.resolved` exists→error. Grounding has the xcodeproj one→pass. | `xcodebuild -resolvePackageDependencies` or open in Xcode; commit Package.resolved. | +| `ios/spm-capacitor-dependency-missing` | error | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists | Package.swift text NOT matching `/capacitor-swift-pm/` OR NOT matching `/\.product\(name:\s*['"]Capacitor['"]/`→error. (File header "DO NOT MODIFY ... managed by Capacitor CLI" → stable to regex.) Grounding matches both→pass. | `npx cap sync ios` to regenerate Package.swift. | + +### 2.F App icons / assets (file: `checks/ios-appicon-checks.ts`) + +Merges research #1 `ios/plist-app-icon` and research #4's three appicon checks into a +clean 3-way split. `readContentsJson` (§1.5) is the shared parse-safety net. + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/appicon-empty-or-placeholder` | error | local | iOS (always) | `dir=appIconSetDir(...)`. `!existsSync(dir)`→error "AppIcon.appiconset missing". Else `c=readContentsJson(dir/Contents.json)`: null→error "missing or malformed". Else `(c.images??[]).filter(i=>i.filename?.trim()).length===0`→error "no icon images". Distinct from marketing-missing (fires regardless of upload). Grounding 1 image→pass. | `npx @capacitor/assets generate` (or Xcode) so the set has ≥ the 1024 icon. | +| `ios/appicon-referenced-file-missing` | error | local | iOS; AppIcon.appiconset/Contents.json exists & parses | For every image entry with non-empty `filename`: `existsSync(join(dir,filename))`. Any missing→error listing missing filenames. Grounding file present→pass. | Regenerate icons or commit the referenced PNG(s). | +| `ios/appicon-marketing-missing` | error | local | `willUploadToAppStore(ctx)` | `c=readContentsJson(...)`; if `!hasMarketingIcon(c)` (no `1024x1024` size and no `role==='marketing'`)→error (ITMS-90704). Upload-gated (ad_hoc/dev don't need the store icon). Grounding has 1024→pass. | Add a 1024x1024 PNG (no alpha) marketing icon; reference it in Contents.json. | + +Note: research #4 `ios/deployment-target-consistency` (SPM `.iOS(.vN)` vs pbxproj) +is included as ONE additional check (below) — it is SPM-specific and distinct from +the Capacitor-floor check in §2.B. + +| id | sev | local | appliesTo (exact) | detection | fix | +|----|-----|-------|-------------------|-----------|-----| +| `ios/spm-deployment-target-consistency` | warning | local | iOS; `ios/App/CapApp-SPM/Package.swift` exists AND `IPHONEOS_DEPLOYMENT_TARGET` PRESENT | `pbxTarget`=parseFloat(readBuildSetting IPHONEOS_DEPLOYMENT_TARGET, app-target Release-pref). `spmMin`=`/\.iOS\(\.v(\d+)\)/` from Package.swift. Warn when `pbxTarget < spmMin` (the dangerous direction — Package requires higher than the app builds against). Numeric major.minor compare. Grounding 15.0 vs .v15→pass. | Raise IPHONEOS_DEPLOYMENT_TARGET to ≥ Package.swift min, or `npx cap sync ios`. | + +--- + +## 3. appliesTo gating summary + +| Gate | Checks | +|------|--------| +| `willUploadToAppStore(ctx)` (upload-gated) | `ios/plist-encryption-compliance`, `ios/appicon-marketing-missing`, `ios/plist-background-modes-sanity` (location-2.5.4 sub-check only) | +| upload escalates severity (warn→error) | `ios/xcode-signing-team`, `ios/capacitor-server-url-shipped`, `ios/plist-ats-arbitrary-loads` | +| `ctx.distributionMode` required | `ios/entitlements-aps-environment-vs-mode` | +| `hasMap(ctx)` (provisioning map present) | `ios/entitlements-vs-profile-capability`; suppresses `ios/xcode-signing-team` | +| File-presence | All Info.plist checks (Info.plist exists); Pods checks (Podfile exists); SPM checks (Package.swift exists); entitlements checks (App.entitlements exists); appicon empty/referenced (always; degrade in run); `ios/xcode-no-app-target` (readPbxproj non-null) | +| Build-setting presence (skip if absent/inherited) | deployment-target, signing-team, bitcode, swift-version, bundle-id-across-configs, spm-deployment-consistency | +| Always (iOS, degrade in run) | `ios/appicon-empty-or-placeholder` | + +--- + +## 4. File / registry plan + +New files under `cli/src/build/prescan/`: + +Parsing helpers: +- `checks/ios-plist-read.ts` — plistString/Bool/HasKey/ArrayStrings/DictBlock +- `ios-buildsettings.ts` — readBuildSetting + resolvePlistValue +- `ios-pbxsettings.ts` — readBuildConfigs + readTargetConfigs (or fold into ios-buildsettings) +- `ios-entitlements.ts` — readAppEntitlements + ent* readers; + MobileprovisionDetail.profileEntitlements extension (edit mobileprovision-parser.ts) +- `ios-appicon.ts` — readContentsJson + appIconSetDir + hasMarketingIcon +- `capacitor-version.ts` — shared capacitorMajor (extracted from android-project.ts) + +Check modules: +- `checks/ios-plist-store.ts` — 11 checks (§2.A) +- `checks/ios-xcode.ts` — 7 checks (§2.B) +- `checks/ios-entitlements-checks.ts` — 4 checks (§2.C) +- `checks/ios-capacitor-config.ts` — 3 checks (§2.D) +- `checks/ios-deps.ts` — 5 checks (§2.E) +- `checks/ios-appicon-checks.ts` — 4 checks (§2.F, incl. spm-deployment-consistency) + +`registry.ts` changes: import each exported check and append to `ALL_CHECKS` under +new grouping comments (`// ios info.plist / app store`, `// ios xcode project`, +`// ios entitlements / capabilities`, `// ios capacitor config`, `// ios pods/spm`, +`// ios app icons`). Also re-point android-project.ts to import `capacitorMajor` +from `../capacitor-version`. + +### Check count + +- Current iOS checks: 10 (p12-opens, p12-expiry, asc-key-valid, asc-key-access, + profile-expiry, profile-bundle-match, profile-type-vs-mode, cert-profile-pairing, + targets-covered, infoplist-sanity). Plus shared/* that also run on iOS. +- NEW iOS checks: **34** + - Info.plist / App Store: 11 + - Xcode project: 7 + - Entitlements/capabilities: 4 + - Capacitor config: 3 + - Pods/SPM: 5 + - App icons/assets: 4 (incl. spm-deployment-consistency) +- **Total iOS after expansion: 44.** + +(34 exceeds the 18-28 target; if trimming is desired, the lowest-value +high-FP/cosmetic 6-8 are flagged in §6 as "optional trims" — cutting those lands +at ~26-28. Recommended baseline ships all 34: each is grounded, low/medium FP, and +the grounding project scans clean.) + +--- + +## 5. Cut list (researched → rejected, with reason) + +| Rejected check (source) | Reason | +|--------------------------|--------| +| `ios/plist-bundle-id-vs-appid` (#1) | Duplicates `shared/bundle-id-consistency` (appId vs resolved bundle id). Instead: EXTEND shared/bundle-id-consistency to escalate to error when `willUploadToAppStore(ctx)` (ASC record match becomes load-bearing). No new check. | +| `ios/plist-deployment-target` (#1) | No real App-Store deployment-target floor exists (Apple's mandate is the build SDK, controlled by the cloud builder). The build-breaking floor is Capacitor's → covered by `ios/xcode-deployment-target-capacitor`. | +| `ios/plist-app-icon` (#1) | Superseded by the §2.F asset-catalog appicon checks (cleaner 3-way split: empty / referenced-missing / marketing-missing). | +| `ios/xcode-automatic-signing-no-team` + `ios/xcode-manual-signing-no-team` (#2) | MERGED into one `ios/xcode-signing-team` (fires for either style when DEVELOPMENT_TEAM missing + no map). Two near-identical checks collapse. | +| `ios/entitlements-icloud-container-coverage` (#3) | Folded into `ios/entitlements-vs-profile-capability` (iCloud keys are just more capability keys; the array-subset logic is identical). The general check's message names the specific key, so the sharper-message rationale is met without a second check. | +| `shared/cap-sync-stale` "Podfile missing" overlap | Existing `shared/cap-sync-stale` already flags missing Podfile on iOS. `ios/pods-not-installed` is scoped to *Podfile-present-but-Pods/-or-workspace-missing* and explicitly does NOT fire when Podfile is absent — no overlap. | +| Any check requiring the built IPA / actool output / binary inspection | Out of static-scan scope (prescan runs pre-build on source). | +| Any "is the app actually using background audio/location" runtime judgement | Out of scope — only structural/invalid-token + missing-usage-string heuristics kept (in `ios/plist-background-modes-sanity`). | +| CFBundleIconName plist-key check | Injected at build time, correctly absent from Capacitor Info.plist → would FP on every healthy app. Asset-catalog detection used instead. | +| Monotonic/increasing CFBundleVersion check | Needs ASC build history (remote/stateful) — out of static scope; format-only kept. | + +### Existing checks to lightly extend (not new modules) + +- `shared/bundle-id-consistency`: add upload-aware severity escalation (warn→error + when `willUploadToAppStore(ctx)`), absorbing the intent of `ios/plist-bundle-id-vs-appid`. +- `ios/infoplist-sanity`: have the presence-only CFBundleShortVersionString / + CFBundleVersion warnings DEFER to the new format checks (or drop the presence + warnings there) so the missing case is reported once, by the stronger check. See + §2.A "null→SKIP" notes — the new format checks intentionally skip the null case to + avoid double-reporting; if infoplist-sanity's presence warnings are removed instead, + flip the format checks to own the null→warning case. Pick ONE owner; spec default: + infoplist-sanity keeps presence, format checks skip null. + +--- + +## 6. Optional trims (to land at 18-28 if mandated) + +Lowest value / highest FP, in trim order (cut top-down): +1. `ios/plist-background-modes-sanity` (high FP, research-flagged). +2. `ios/plist-iphoneos-required` (cosmetic structural sanity; Capacitor always sets it). +3. `ios/plist-display-name` (cosmetic; grounding passes via literal). +4. `ios/xcode-multiple-app-targets` (rare; bad-merge only). +5. `ios/entitlements-app-groups-format` (format-only; rare in Capacitor apps). +6. `ios/capacitor-allow-navigation-wildcard` (security smell, not build-breaking). +7. `ios/spm-deployment-target-consistency` (warning-only; pbxproj≥spm usually fine). +8. `ios/plist-orientations-present` (Apple lenient on iPhone orientations). + +Cutting 1-8 → 26 iOS checks (within target). Recommended: ship all 34 (each grounded, +clean against the real project). + +--- + +## 7. TDD acceptance fixtures + +1. **Healthy SPM project** (the grounding tutorial-app): every new check returns `[]`. + This is the primary regression fixture — copy the real files into a test fixture dir. +2. **Per-check failing fixture**: minimal mutation of the healthy fixture (e.g. + `CFBundleShortVersionString` literal `1.0-beta`; `aps-environment` + `distributionMode=app_store`; + delete `AppIcon-512@2x.png`; `server.url` set; `IPHONEOS_DEPLOYMENT_TARGET=12.0` for Cap8). +3. **Unresolved-var fixture**: Info.plist `$()` refs with NO matching pbxproj setting → + value-format checks must SKIP (no finding), proving the resolvePlistValue guard. +4. **Non-Capacitor / partial project**: missing Info.plist/pbxproj/appiconset → all + checks early-return `[]` (no crashes).