Skip to content

Commit c337763

Browse files
chrfalchclaude
andcommitted
fix(ios-prebuild): allowlist + drift gate for React.framework privacy manifests
collectReactPrivacyManifestPaths merged ANY PrivacyInfo.xcprivacy found recursively under the React privacy roots — a latent trap: a future pod under those roots that ships as its OWN framework would silently have its manifest folded into React.framework's aggregate (over-declaration in the app's privacy report). The scan is now validated against an explicit REACT_PRIVACY_MANIFESTS allowlist and FAILS the prebuild on an unlisted manifest, with instructions for both resolutions. Listed-but-absent stays legal: partial fixture trees keep working, an upstream deletion under-declares exactly as a source build would, and a MOVED manifest cannot slip through since its new path is unlisted. Also: only create the i18n temp stage when there are .lproj dirs to bundle, and document that the dedup key's `?? ''` is Flow appeasement (JSON.stringify is typed `string | void`), not dead code. 56 ios-prebuild tests green (drift-gate cases red-first); Flow + prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3aa78b0 commit c337763

3 files changed

Lines changed: 88 additions & 10 deletions

File tree

packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,43 @@ describe('serialize/read privacy-manifest round-trip', () => {
184184
});
185185
});
186186

187+
describe('collectReactPrivacyManifestPaths drift gate', () => {
188+
let tmp;
189+
190+
beforeEach(() => {
191+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-gate-'));
192+
});
193+
194+
afterEach(() => {
195+
fs.rmSync(tmp, {recursive: true, force: true});
196+
});
197+
198+
function writeManifest(rel) {
199+
const file = path.join(tmp, rel);
200+
fs.mkdirSync(path.dirname(file), {recursive: true});
201+
fs.writeFileSync(file, serializePrivacyManifest(reactCore));
202+
return file;
203+
}
204+
205+
it('throws when a manifest under the privacy roots is not allowlisted (a new pod must be a conscious decision)', () => {
206+
writeManifest('React/Resources/PrivacyInfo.xcprivacy');
207+
writeManifest('Libraries/SomeNewPod/PrivacyInfo.xcprivacy');
208+
expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow(/SomeNewPod/);
209+
expect(() => collectReactPrivacyManifestPaths(tmp)).toThrow(
210+
/REACT_PRIVACY_MANIFESTS/,
211+
);
212+
});
213+
214+
it('returns the found subset of allowlisted manifests (partial trees stay valid)', () => {
215+
const file = writeManifest('React/Resources/PrivacyInfo.xcprivacy');
216+
expect(collectReactPrivacyManifestPaths(tmp)).toEqual([file]);
217+
});
218+
219+
it('returns [] for a tree with no manifests', () => {
220+
expect(collectReactPrivacyManifestPaths(tmp)).toEqual([]);
221+
});
222+
});
223+
187224
describe('buildReactPrivacyManifest (against the real source tree)', () => {
188225
it('discovers React-core PrivacyInfo.xcprivacy files (not third-party deps)', () => {
189226
const paths = collectReactPrivacyManifestPaths(RN_PATH);

packages/react-native/scripts/ios-prebuild/framework-resources.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ const plist = require('plist');
3333
// ReactNativeDependencies.xcframework and are aggregated there.
3434
const REACT_PRIVACY_ROOTS = ['React', 'ReactCommon', 'Libraries', 'ReactApple'];
3535

36+
// The privacy manifests of the pods that compile INTO React.framework —
37+
// an EXPLICIT allowlist, not a glob: a PrivacyInfo.xcprivacy that appears
38+
// under REACT_PRIVACY_ROOTS without being listed here fails the prebuild,
39+
// forcing a conscious decision. A pod whose sources compile into
40+
// React.framework belongs on this list; a pod that ships as its OWN framework
41+
// must NOT have its manifest folded into React.framework's aggregate
42+
// (over-declaration in the app's privacy report).
43+
const REACT_PRIVACY_MANIFESTS = [
44+
path.join('React', 'Resources', 'PrivacyInfo.xcprivacy'),
45+
path.join('ReactCommon', 'cxxreact', 'PrivacyInfo.xcprivacy'),
46+
path.join('ReactCommon', 'react', 'timing', 'PrivacyInfo.xcprivacy'),
47+
];
48+
3649
// Where React-Core's localized strings live, relative to the package root.
3750
const STRINGS_REL = path.join('React', 'I18n', 'strings');
3851

@@ -122,6 +135,9 @@ function mergePrivacyManifests(
122135
for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) {
123136
// Canonicalize (sort object keys recursively) before keying so two pods
124137
// declaring the same data-type dict in different key order still dedup.
138+
// The `?? ''` is unreachable at runtime (canonicalize of a plist dict
139+
// never yields undefined) — it exists purely because Flow types
140+
// JSON.stringify as `string | void`.
125141
const key = JSON.stringify(canonicalize(dataType)) ?? '';
126142
if (!collectedSeen.has(key)) {
127143
collectedSeen.add(key);
@@ -174,11 +190,27 @@ function collectReactPrivacyManifestPaths(
174190
}
175191
for (const rel of fs.readdirSync(dir, {recursive: true})) {
176192
if (path.basename(String(rel)) === 'PrivacyInfo.xcprivacy') {
177-
found.push(path.join(dir, String(rel)));
193+
found.push(path.join(root, String(rel)));
178194
}
179195
}
180196
}
181-
return found.sort();
197+
const unlisted = found.filter(rel => !REACT_PRIVACY_MANIFESTS.includes(rel));
198+
if (unlisted.length > 0) {
199+
throw new Error(
200+
'React.framework privacy-manifest drift: found PrivacyInfo.xcprivacy ' +
201+
'file(s) under the React privacy roots that are not allowlisted:\n' +
202+
unlisted.map(rel => ` ${rel}`).join('\n') +
203+
'\nIf the owning pod compiles into React.framework, add the path to ' +
204+
'REACT_PRIVACY_MANIFESTS in framework-resources.js. If it ships as ' +
205+
'its own framework, its manifest must NOT be folded into ' +
206+
"React.framework's aggregate — relocate it out of the privacy roots " +
207+
'or exclude it explicitly.',
208+
);
209+
}
210+
// A listed-but-absent manifest is legal (partial fixture trees; upstream
211+
// deletions surface as under-declaration exactly like source builds would).
212+
// A MOVED manifest cannot slip through: its new location is unlisted.
213+
return found.map(rel => path.join(reactNativePath, rel)).sort();
182214
}
183215

184216
/**

packages/react-native/scripts/ios-prebuild/headers-compose.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
const {
2121
buildI18nStringsBundle,
2222
buildReactPrivacyManifest,
23+
collectLprojDirs,
2324
serializePrivacyManifest,
2425
} = require('./framework-resources');
2526
const {computeInventory} = require('./headers-inventory');
@@ -152,12 +153,18 @@ function emitReactFrameworkHeaders(
152153

153154
// Build RCTI18nStrings.bundle ONCE into a temp stage, then clone it into each
154155
// slice below — mirrors the privacy manifest (computed once, embedded per
155-
// slice) instead of rebuilding the bundle inside the slice loop.
156-
const i18nStage = fs.mkdtempSync(
157-
path.join(path.dirname(xcfwPath), '.i18n-stage-'),
158-
);
159-
const i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle');
160-
const i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage);
156+
// slice) instead of rebuilding the bundle inside the slice loop. The stage is
157+
// only created when there are locales to bundle.
158+
let i18nStage = null;
159+
let i18nBundleStage = null;
160+
let i18nLocales = 0;
161+
if (collectLprojDirs(rnRoot).length > 0) {
162+
i18nStage = fs.mkdtempSync(
163+
path.join(path.dirname(xcfwPath), '.i18n-stage-'),
164+
);
165+
i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle');
166+
i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage);
167+
}
161168

162169
for (const slice of slices) {
163170
const fwk = path.join(xcfwPath, slice, 'React.framework');
@@ -177,14 +184,16 @@ function emitReactFrameworkHeaders(
177184
}
178185
// Clone the prebuilt RCTI18nStrings.bundle so the framework-aware
179186
// RCTLocalizedString loader resolves React-Core's strings in prebuilt/SPM.
180-
if (i18nLocales > 0) {
187+
if (i18nLocales > 0 && i18nBundleStage != null) {
181188
const dest = path.join(fwk, 'RCTI18nStrings.bundle');
182189
fs.rmSync(dest, {recursive: true, force: true});
183190
execFileSync('/bin/cp', [CP_FLAGS, i18nBundleStage, dest]);
184191
}
185192
}
186193
fs.rmSync(stage, {recursive: true, force: true});
187-
fs.rmSync(i18nStage, {recursive: true, force: true});
194+
if (i18nStage != null) {
195+
fs.rmSync(i18nStage, {recursive: true, force: true});
196+
}
188197
console.log(
189198
`headers-compose: React.framework spec layout -> ${slices.join(', ')} ` +
190199
`(${plan.react.length} headers, umbrella ${plan.umbrella.length}` +

0 commit comments

Comments
 (0)