storebound/cli/lib/privacy-files.ts

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;
}