ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree in every privacy manifest (true needs domains, domains need true); check, scan and privacy-manifest fail on either mismatch with the exact ITMS text; App Privacy tracking no longer demands the app manifest's true: SDK manifests that declare tracking (Pods, frameworks, SwiftPM, Carthage, plugins, the built IPA) are info, known tracking SDKs info, else a warning; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (XML or binary) before any network call, apple build after the export. Pitfall 29
This commit is contained in:
parent
b7bfe89151
commit
c8213be2b6
19 changed files with 631 additions and 44 deletions
115
cli/lib/privacy-files.ts
Normal file
115
cli/lib/privacy-files.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// 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 { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { basename, join, relative, sep } from 'node:path';
|
||||
import { nativeIosPlugins } from './node-packages';
|
||||
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 names: string[] = [];
|
||||
try { names = readdirSync(dir).sort(); } catch { return; }
|
||||
for (const n of names) {
|
||||
if (--budget.n <= 0) return;
|
||||
const p = join(dir, n);
|
||||
let st;
|
||||
try { st = statSync(p); } catch { continue; }
|
||||
const descend = visit(p, st.isDirectory());
|
||||
if (st.isDirectory() && 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));
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue