From 73db689386cc0331c8772550a50ed56e9cfb0a28 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 13:14:49 +0800 Subject: [PATCH] privacy manifest discovery skips build-output and copy folders (hidden dirs like .native-build, build, DerivedData, node_modules, dist, Products, built .app / .xcarchive) in scan, check and privacy-manifest: a stale .native-build copy was checked instead of the real ios/ manifest; test --- cli/lib/privacy-manifest.ts | 11 +++++++++++ cli/lib/scan/project.ts | 4 ++-- cli/privacy-manifest.ts | 4 ++-- tests/privacy-build-copy.test.ts | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 tests/privacy-build-copy.test.ts diff --git a/cli/lib/privacy-manifest.ts b/cli/lib/privacy-manifest.ts index f5ef0f3..d53441f 100644 --- a/cli/lib/privacy-manifest.ts +++ b/cli/lib/privacy-manifest.ts @@ -230,3 +230,14 @@ export function evaluatePrivacyManifest(i: PmInput): PmFinding[] { const order: Record = { blocker: 0, warn: 1, info: 2, ok: 3 }; return out.sort((a, b) => order[a.level] - order[b.level]); } + +const COPY_DIRS = new Set(['build', 'Build', 'DerivedData', 'node_modules', 'dist', 'Products', 'Intermediates.noindex', 'ArchiveIntermediates']); + +/** A PrivacyInfo.xcprivacy path (relative to the project) that is build output or a copy, not the app's own manifest: + * under a hidden folder (.native-build, .output, .build), build/, DerivedData, node_modules, dist, Xcode Products, + * or inside a built .app / .xcarchive. Seen live: a stale .native-build/apps/native/ios copy was checked instead of + * the real apps/native/ios manifest. */ +export function isBuildCopyPath(rel: string): boolean { + const dirs = rel.split(/[\\/]/).slice(0, -1); + return dirs.some(d => (d.startsWith('.') && d !== '.' && d !== '..') || COPY_DIRS.has(d) || /\.(app|xcarchive)$/.test(d)); +} diff --git a/cli/lib/scan/project.ts b/cli/lib/scan/project.ts index 4dd9fae..dd45fc7 100644 --- a/cli/lib/scan/project.ts +++ b/cli/lib/scan/project.ts @@ -6,7 +6,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, extname, join, relative, sep } from 'node:path'; import { resolvePackage } from '../node-packages'; -import { type Manifest, parseManifest } from '../privacy-manifest'; +import { isBuildCopyPath, type Manifest, parseManifest } from '../privacy-manifest'; import { type DepRef, type Eco, matchesSdk, SDKS, type SdkDef } from './sdks'; export interface Loc { file: string; line: number } @@ -371,7 +371,7 @@ export function detectProject(root: string): ProjectFacts { } else if (n.endsWith('.entitlements')) { const { value, lines } = parsePlist(read(f)); for (const [k, v] of Object.entries(value)) setKey(entitlements, k, v, { file: f, line: lines.get(k) ?? 1 }); - } else if (n === 'PrivacyInfo.xcprivacy') privacyManifests.push(parseManifest(f, read(f))); + } else if (n === 'PrivacyInfo.xcprivacy') { if (!isBuildCopyPath(f)) privacyManifests.push(parseManifest(f, read(f))); } else if (n === 'project.pbxproj') { const t = read(f); deps.push(...pbxproj(f, t)); diff --git a/cli/privacy-manifest.ts b/cli/privacy-manifest.ts index f5c901d..97ffc57 100644 --- a/cli/privacy-manifest.ts +++ b/cli/privacy-manifest.ts @@ -21,7 +21,7 @@ import { nativeIosPlugins } from './lib/node-packages'; import { parsePlist, toXmlPlist, type PlistValue } from './lib/plist'; import { manifestFromBytes } from './lib/privacy-files'; import type { AppleDataTypeId, AppleTypeAnswer } from './lib/app-privacy'; -import { collectedFromAppPrivacy, evaluatePrivacyManifest, type Manifest, parseManifest, proposeAppManifest, scanSources, type SdkInfo } from './lib/privacy-manifest'; +import { collectedFromAppPrivacy, evaluatePrivacyManifest, isBuildCopyPath, type Manifest, parseManifest, proposeAppManifest, scanSources, type SdkInfo } from './lib/privacy-manifest'; const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata']); const SRC = /\.(swift|m|mm|c|cc|cpp|h|hpp)$/; @@ -105,7 +105,7 @@ export async function run(args: Args): Promise { return true; } const n = basename(p); - if (n === 'PrivacyInfo.xcprivacy') manifests.push(p); + if (n === 'PrivacyInfo.xcprivacy') { if (!isBuildCopyPath(relative(root, p))) manifests.push(p); } else if (n === 'Info.plist' && !infoPlist && !/Tests?\//.test(p)) infoPlist = readFileSync(p, 'utf8'); else if (SRC.test(n) && statSync(p).size < 2_000_000) sources.push({ path: relative(process.cwd(), p), text: readFileSync(p, 'utf8') }); }); diff --git a/tests/privacy-build-copy.test.ts b/tests/privacy-build-copy.test.ts new file mode 100644 index 0000000..c5eadc5 --- /dev/null +++ b/tests/privacy-build-copy.test.ts @@ -0,0 +1,34 @@ +// A stale build-output copy of the app's PrivacyInfo.xcprivacy (seen live: .native-build/apps/native/ios/App/App/) +// must not be read as the app's own manifest by check / scan / privacy-manifest. Placeholder data only. +import { afterAll, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isBuildCopyPath } from '../cli/lib/privacy-manifest'; +import { detectProject } from '../cli/lib/scan/project'; + +const tmp = mkdtempSync(join(tmpdir(), 'sb-pm-copy-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const manifest = (collected: boolean) => ` +NSPrivacyTrackingNSPrivacyCollectedDataTypes${collected + ? 'NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeCrashDataNSPrivacyCollectedDataTypeLinkedNSPrivacyCollectedDataTypeTrackingNSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality' + : ''}NSPrivacyAccessedAPITypes`; + +test('isBuildCopyPath: hidden, build and copy folders are skipped, the real ios tree is not', () => { + for (const p of ['.native-build/apps/native/ios/App/App/PrivacyInfo.xcprivacy', 'ios/build/App.app/PrivacyInfo.xcprivacy', + 'DerivedData/x/PrivacyInfo.xcprivacy', 'dist/ios/PrivacyInfo.xcprivacy', '.output/PrivacyInfo.xcprivacy', 'node_modules/a/PrivacyInfo.xcprivacy']) + expect(isBuildCopyPath(p)).toBe(true); + for (const p of ['apps/native/ios/App/App/PrivacyInfo.xcprivacy', 'ios/App/App/PrivacyInfo.xcprivacy', 'PrivacyInfo.xcprivacy']) + expect(isBuildCopyPath(p)).toBe(false); +}); + +test('scan facts: the real manifest is found, the stale .native-build copy is not', () => { + const real = join(tmp, 'apps/native/ios/App/App'); + const copy = join(tmp, '.native-build/apps/native/ios/App/App'); + mkdirSync(real, { recursive: true }); + mkdirSync(copy, { recursive: true }); + writeFileSync(join(real, 'PrivacyInfo.xcprivacy'), manifest(true)); + writeFileSync(join(copy, 'PrivacyInfo.xcprivacy'), manifest(false)); + expect(detectProject(tmp).privacyManifests.map(m => m.path)).toEqual(['apps/native/ios/App/App/PrivacyInfo.xcprivacy']); +});