117 lines
5.6 KiB
TypeScript
117 lines
5.6 KiB
TypeScript
// 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>.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<string>();
|
|
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;
|
|
}
|