// Privacy manifests on disk and inside a built app: the SDK manifests of an iOS project (Pods, .framework / // .xcframework, SwiftPM checkouts, Capacitor / Cordova / React Native plugins in node_modules, a built .app's // Frameworks and bundles) and every PrivacyInfo.xcprivacy inside an .ipa or .app (XML or binary). Read-only; the // directory walk is bounded. import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, join, relative, sep } from 'node:path'; import { nativeIosPlugins } from './node-packages'; import { skipDir } from './walk-skip'; import { parseBinaryPlist } from './plist'; import { type Manifest, manifestFromPlist, parseManifest, trackingIssue } from './privacy-manifest'; import { zipEntries, zipRead } from './zip'; /** A manifest read from bytes: binary plists (common in built apps) and XML. */ export function manifestFromBytes(path: string, b: Buffer): Manifest { if (b.subarray(0, 8).toString('latin1') === 'bplist00') return manifestFromPlist(path, parseBinaryPlist(b)); return parseManifest(path, b.toString('utf8')); } export interface BundleManifest { /** Path inside the app bundle ("PrivacyInfo.xcprivacy", "Frameworks/X.framework/PrivacyInfo.xcprivacy"). */ inApp: string; /** The SDK the manifest belongs to (framework, bundle, plugin), null for the app's own. */ sdk: string | null; manifest: Manifest; } /** The SDK name of a path inside an app bundle (null: the app's own manifest at the bundle root). */ export function sdkOfBundlePath(inApp: string): string | null { const parts = inApp.split('/'); for (let k = parts.length - 2; k >= 0; k--) { const m = /^(.+)\.(framework|bundle|appex|xctest)$/.exec(parts[k]!); if (m) return m[1]!; } return null; } /** Every PrivacyInfo.xcprivacy inside an .ipa (Payload/.app/...). */ export function ipaManifests(ipa: string | Buffer): BundleManifest[] { const out: BundleManifest[] = []; for (const e of zipEntries(ipa)) { const m = /^Payload\/[^/]+\.app\/(.*PrivacyInfo\.xcprivacy)$/.exec(e.name); if (!m || e.name.endsWith('/')) continue; out.push({ inApp: m[1]!, sdk: sdkOfBundlePath(m[1]!), manifest: manifestFromBytes(e.name, zipRead(ipa, e)) }); } return out; } /** Every PrivacyInfo.xcprivacy inside a built .app folder. */ export function appDirManifests(app: string): BundleManifest[] { const out: BundleManifest[] = []; walk(app, (p, isDir) => { if (isDir) return true; if (basename(p) !== 'PrivacyInfo.xcprivacy') return; const inApp = relative(app, p).split(sep).join('/'); try { out.push({ inApp, sdk: sdkOfBundlePath(inApp), manifest: manifestFromBytes(p, readFileSync(p)) }); } catch { /* unreadable */ } }); return out; } /** ITMS-91064 problems of a built app's manifests (app root, Frameworks/*.framework, bundles), one message each. */ export function bundleTrackingIssues(list: BundleManifest[]): string[] { return list.flatMap(b => { const i = trackingIssue(b.manifest, b.sdk, b.inApp); return i ? [i] : []; }); } function walk(dir: string, visit: (p: string, isDir: boolean) => boolean | void, depth = 0, budget = { n: 60_000 }): void { if (depth > 16 || budget.n <= 0) return; let ents: Dirent[] = []; try { ents = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } catch { return; } for (const e of ents) { if (--budget.n <= 0) return; const p = join(dir, e.name); // the entry type comes with the listing (no stat per file: a stat each made a big tree take minutes on Windows) let isDir = e.isDirectory(); if (e.isSymbolicLink()) { try { isDir = statSync(p).isDirectory(); } catch { continue; } } const descend = visit(p, isDir); if (isDir && descend !== false) walk(p, visit, depth + 1, budget); } } const SKIP = new Set(['.git', 'node_modules', '.gradle', '.dart_tool', '.storebound', '.claude', '.cursor', 'android', '.next', 'coverage', 'xcuserdata']); /** The SDK a manifest path in a project belongs to, or null when it is the app's own (not under an SDK folder). */ export function sdkOfProjectPath(rel: string): string | null { const parts = rel.split('/'); for (let k = parts.length - 2; k >= 0; k--) { const p = parts[k]!; const fw = /^(.+)\.(xc)?framework$|^(.+)\.bundle$/.exec(p); if (fw) return fw[1] ?? fw[3]!; if (k > 0 && (parts[k - 1] === 'Pods' || parts[k - 1] === 'checkouts' || parts[k - 1] === 'Carthage')) return p; } return null; } export interface SdkManifest { sdk: string; manifest: Manifest } /** SDK manifests of an iOS / cross-platform project: Pods, .framework / .xcframework, SwiftPM checkouts, Carthage, * built .app Frameworks, and the native plugins found through node_modules. Paths relative to `root`. */ export function projectSdkManifests(root: string): SdkManifest[] { const out: SdkManifest[] = []; const seen = new Set(); const add = (abs: string, sdk: string) => { if (seen.has(abs)) return; seen.add(abs); try { out.push({ sdk, manifest: manifestFromBytes(relative(root, abs).split(sep).join('/'), readFileSync(abs)) }); } catch { /* unreadable */ } }; walk(root, (p, isDir) => { if (isDir) return !SKIP.has(basename(p)) && !skipDir(root, p, basename(p)); if (basename(p) !== 'PrivacyInfo.xcprivacy') return; const sdk = sdkOfProjectPath(relative(root, p).split(sep).join('/')); if (sdk) add(p, sdk); }); for (const pl of nativeIosPlugins(root)) walk(pl.dir, (p, isDir) => { if (isDir) return basename(p) !== 'node_modules' && basename(p) !== '.git'; if (basename(p) === 'PrivacyInfo.xcprivacy') add(p, pl.name); }); return out; }