storebound/cli/lib/targets.ts

184 lines
12 KiB
TypeScript

// Signable targets of an Xcode project (the app, app extensions such as a share extension or widget, the watch app, an
// App Clip), read from project.pbxproj: product type, PRODUCT_BUNDLE_IDENTIFIER and CODE_SIGN_ENTITLEMENTS of the
// Release configuration. Pure parsing; the fs side (entitlements files, config override) is cli/apple/signing.ts.
// Also the App Group check: Apple's API can switch the APP_GROUPS capability on for a bundle id but cannot assign a
// group container to it, so a profile carries `com.apple.security.application-groups` only when the group was ticked in
// the developer portal; comparing the target's entitlements with the profile's entitlements finds the gap offline.
export type TargetKind = 'app' | 'extension' | 'watch' | 'clip';
export interface PbxTarget { name: string; productType: string; kind: TargetKind; /** null: not resolvable (set in an xcconfig or a variable) */ bundleId: string | null; unresolved?: string; entitlements: string | null }
/** Product types that are code signed with their own bundle id and provisioning profile. Frameworks, bundles, test
* bundles, tools and libraries are not. */
export function targetKind(productType: string): TargetKind | null {
const m = /^com\.apple\.product-type\.(.+)$/.exec(productType);
const t = m?.[1];
if (!t) return null;
if (t === 'application.on-demand-install-capable') return 'clip';
if (/^application\.watchapp/.test(t) || /^watchkit2?-extension$/.test(t)) return 'watch';
if (t === 'application') return 'app';
if (/^(app-extension(\..+)?|extensionkit-extension|tv-app-extension)$/.test(t)) return 'extension';
return null;
}
/** id -> body of every `<24 hex id> /* comment *\/ = { ... };` object (brace matched, quoted strings skipped). */
function pbxObjects(pbx: string): Map<string, string> {
const out = new Map<string, string>();
const re = /^[ \t]*([0-9A-Fa-f]{24})(?:[ \t]*\/\*[\s\S]*?\*\/)?[ \t]*=[ \t]*\{/gm;
for (let m = re.exec(pbx); m; m = re.exec(pbx)) {
const open = m.index + m[0].length - 1;
let depth = 0, q = false, i = open;
for (; i < pbx.length; i++) {
const ch = pbx[i];
if (q) { if (ch === '\\') i++; else if (ch === '"') q = false; continue; }
if (ch === '"') q = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) break;
}
out.set(m[1]!, pbx.slice(open + 1, i));
re.lastIndex = i;
}
return out;
}
const unquote = (s: string): string => { const t = s.trim(); return t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1).replace(/\\"/g, '"') : t; };
const field = (body: string, name: string): string | null => {
const m = new RegExp(`(?:^|[;\\s])${name}\\s*=\\s*("(?:[^"\\\\]|\\\\.)*"|[^;\\s]+)\\s*(?:/\\*[\\s\\S]*?\\*/)?\\s*;`).exec(body);
return m ? unquote(m[1]!) : null;
};
function buildSettings(body: string): Record<string, string> {
const at = body.search(/buildSettings\s*=\s*\{/);
if (at < 0) return {};
const inner = body.slice(body.indexOf('{', at) + 1);
const out: Record<string, string> = {};
for (const m of inner.matchAll(/^[ \t]*("?[A-Za-z_][A-Za-z0-9_\[\]=*]*"?)[ \t]*=[ \t]*("(?:[^"\\]|\\.)*"|\([^)]*\)|[^;]*);/gm)) out[unquote(m[1]!)] = unquote(m[2]!);
return out;
}
/** $(VAR) / ${VAR} from the target's own settings, TARGET_NAME and PRODUCT_NAME; a modifier (:rfc1034identifier)
* makes the value an identifier. Anything still containing $( afterwards is unresolved. */
export function resolveSetting(value: string, vars: Record<string, string>): string {
let v = value;
for (let n = 0; n < 5 && /\$[({]/.test(v); n++) {
v = v.replace(/\$[({]([A-Za-z0-9_]+)(?::([a-z0-9]+))?[)}]/g, (all, name: string, mod?: string) => {
const r = vars[name];
if (r === undefined || r === `$(${name})`) return all;
return mod === 'rfc1034identifier' || mod === 'identifier' ? r.replace(/[^A-Za-z0-9.-]/g, '-') : r;
});
}
return v;
}
/** The signable targets of one project.pbxproj, in file order. */
export function pbxTargets(pbx: string): PbxTarget[] {
const objs = pbxObjects(pbx);
const out: PbxTarget[] = [];
for (const body of objs.values()) {
if (field(body, 'isa') !== 'PBXNativeTarget') continue;
const productType = field(body, 'productType') ?? '';
const kind = targetKind(productType);
if (!kind) continue;
const name = field(body, 'name') ?? field(body, 'productName') ?? '?';
const listBody = objs.get((field(body, 'buildConfigurationList') ?? '').split(/\s/)[0]!);
const ids = [...(/buildConfigurations\s*=\s*\(([\s\S]*?)\)/.exec(listBody ?? '')?.[1] ?? '').matchAll(/([0-9A-Fa-f]{24})/g)].map(m => m[1]!);
const cfgs = ids.map(id => objs.get(id)).filter((b): b is string => !!b);
const cfg = cfgs.find(c => field(c, 'name') === 'Release') ?? cfgs[cfgs.length - 1];
const s = cfg ? buildSettings(cfg) : {};
const vars: Record<string, string> = { ...s, TARGET_NAME: name, PRODUCT_NAME: resolveSetting(s.PRODUCT_NAME ?? name, { TARGET_NAME: name }) };
const raw = s.PRODUCT_BUNDLE_IDENTIFIER;
const bundleId = raw ? resolveSetting(raw, vars) : null;
const ent = s.CODE_SIGN_ENTITLEMENTS ? resolveSetting(s.CODE_SIGN_ENTITLEMENTS, { ...vars, SRCROOT: '' }).replace(/^\/+/, '') : null;
out.push({ name, productType, kind, bundleId: bundleId && !/\$[({]/.test(bundleId) ? bundleId : null, ...(raw && (!bundleId || /\$[({]/.test(bundleId)) ? { unresolved: raw } : {}), entitlements: ent });
}
return out;
}
/** App groups (`com.apple.security.application-groups`) a profile's Entitlements allow. */
export const profileAppGroups = (ent: Record<string, unknown> | undefined): string[] => {
const g = ent?.['com.apple.security.application-groups'];
return Array.isArray(g) ? g.map(String) : [];
};
/** Groups the target's entitlements need that the profile does not carry. `*` in a profile allows any group. */
export function appGroupGaps(want: string[], profileEnt: Record<string, unknown> | undefined): string[] {
const have = profileAppGroups(profileEnt);
if (have.includes('*')) return [];
return want.filter(g => !have.includes(g));
}
/** The developer portal page of a bundle id (the id is the App Store Connect API resource id of the bundle id). */
export const bundleIdPortalUrl = (resourceId: string): string => `https://developer.apple.com/account/resources/identifiers/bundleId/edit/${encodeURIComponent(resourceId)}`;
export interface AppGroupGap { bundleId: string; resourceId: string | null; groups: string[]; url: string | null; /** why it is a gap / not proven (one line) */ why?: string }
export const PORTAL_IDENTIFIERS_URL = 'https://developer.apple.com/account/resources/identifiers/list';
/** The agent-first steps to tick the groups (the API cannot do it); the never-press list is part of the text.
* The first line is the headline, the rest are the steps. */
export function appGroupSteps(g: AppGroupGap): string[] {
const url = g.url ?? PORTAL_IDENTIFIERS_URL;
return [
`${g.bundleId}: ${g.why ?? `the profile lacks App Group(s) ${g.groups.join(', ')}`} (the App Store Connect API can enable the App Groups capability but cannot assign a group to a bundle id).`,
`Open ${url} in the signed-in browser (agent-first: you drive it; plan: storebound console plan apple-app-group --bundle-id ${g.bundleId}${g.url ? '' : ' --lookup'}).`,
`Certificates, Identifiers & Profiles > Identifiers > ${g.bundleId} > App Groups (tick it if it is not ticked) > Configure > tick ${g.groups.join(', ')} (register a new group first under Identifiers > App Groups) > Continue > Save (and Confirm in the dialog that says the profiles get invalidated).`,
'Never press: Remove (the bundle id), Delete, Revoke, or any Certificates / Keys / Account button; change no other capability.',
'Then run: storebound apple signing ensure --refresh-profile (makes a new profile that carries the group), and storebound apple signing status to read it back.',
];
}
/** Groups missing per bundle id -> one gap row each. `groupsOf` and `profileOf` are looked up by bundle id. */
export function appGroupGapsFor(targets: { bundleId: string; appGroups: string[] }[], profileEnt: (bundleId: string) => Record<string, unknown> | undefined | null, resourceId: (bundleId: string) => string | null): AppGroupGap[] {
const out: AppGroupGap[] = [];
for (const t of targets) {
if (!t.appGroups.length) continue;
const ent = profileEnt(t.bundleId);
if (ent === null) continue; // no readable profile: unknown, not a gap
const groups = appGroupGaps(t.appGroups, ent);
if (groups.length) { const rid = resourceId(t.bundleId); out.push({ bundleId: t.bundleId, resourceId: rid, groups, url: rid ? bundleIdPortalUrl(rid) : null }); }
}
return out;
}
/** What the API (or an installed profile) shows about one App ID's App Group association. */
export interface GroupEvidence {
/** App Store Connect API resource id of the bundle id (the portal deep link); null: not registered / unknown */
resourceId: string | null;
/** the APP_GROUPS capability is on the App ID (null: unknown) */
capabilityOn: boolean | null;
/** the raw settings of the APP_GROUPS capability (any field that names a group proves it) */
capabilitySettings?: unknown;
/** Entitlements of the newest readable profile of the bundle id (a managed one too); null: none readable */
profileEnt: Record<string, unknown> | null;
}
/** Groups of `want` that the capability's settings name (the API has no documented App Group field; if a field ever
* lists them, this proves the association without a profile). */
export const capabilityGroups = (settings: unknown, want: string[]): string[] => {
if (settings === null || settings === undefined) return [];
const text = JSON.stringify(settings);
return want.filter(g => text.includes(g));
};
export interface GroupVerdicts { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }
/**
* Every target whose entitlements list App Groups: proven (a readable profile carries each group, or the capability
* settings name it), a gap (the evidence shows the group missing: a readable profile without it, or the capability
* off) or unproven (nothing readable: the API cannot show the association). Pure.
*/
export function appGroupVerdicts(targets: { bundleId: string; appGroups: string[] }[], evidence: (bundleId: string) => GroupEvidence | null): GroupVerdicts {
const out: GroupVerdicts = { gaps: [], unproven: [], ok: [] };
for (const t of targets) {
if (!t.appGroups.length) continue;
const ev = evidence(t.bundleId);
const rid = ev?.resourceId ?? null, url = rid ? bundleIdPortalUrl(rid) : null;
const viaCap = new Set(capabilityGroups(ev?.capabilitySettings, t.appGroups));
const missing = (ev?.profileEnt ? appGroupGaps(t.appGroups, ev.profileEnt) : t.appGroups).filter(g => !viaCap.has(g));
if (!missing.length) { out.ok.push(t.bundleId); continue; }
if (ev?.capabilityOn === false) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the App Groups capability is not enabled on the App ID, so ${missing.join(', ')} is not associated` });
else if (ev?.profileEnt) out.gaps.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: `the newest profile lacks App Group(s) ${missing.join(', ')} (an empty application-groups list means the group is not ticked on the App ID)` });
else out.unproven.push({ bundleId: t.bundleId, resourceId: rid, groups: missing, url, why: ev ? `App Group(s) ${missing.join(', ')} cannot be proven: the API shows no group on the App ID's capability and no readable profile` : `App Group(s) ${missing.join(', ')} cannot be proven: the App ID is not registered or could not be read` });
}
return out;
}