Skip to content

Commit 24a65c9

Browse files
chrfalchclaude
andcommitted
fix(ios-prebuild): address review — enforce inventory collisions, harden compose freshness + shell usage
Review feedback on the header modularization (VFS-overlay removal): - computeInventory() now returns natural-path collisions; computeSpecPlan() fails closed on them (R8) so two sources projecting to the same Headers/ path can no longer be silently merged (only identities[0].source was kept). - ensureHeadersLayout()'s freshness marker folds a sha256 of the compose tooling (headers-{inventory,spec,compose}.js) so a local script edit forces a recompose — the source xcframework's Info.plist mtime alone couldn't. - scanHeader() strips /* ... */ block comments (multi-line aware) so a documentation line mentioning namespace/template/constexpr can't trip the C++ detector and shrink the umbrella. - Anonymous aggregates with C++ member initializers (typedef struct { x = ..; }) are now detected as ObjC++ (tag name made optional). - Multi-hyphen pod names namespaced correctly (.replace(/-/g,'_')). - cp/tar shell-outs switched to execFileSync with arg arrays (CodeQL "command from environment values"; parity with headers-verify.js). - Clearer hermes-missing warning; headers-spec R9/R10 added to the contract docblock (was R1–R8 then R11). - New headers-inventory-test.js covering scanHeader (cxx guards, #else/#elif flips, anonymous aggregate, block/line comments). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b4b1a94 commit 24a65c9

5 files changed

Lines changed: 231 additions & 25 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
const {scanHeader} = require('../headers-inventory');
14+
15+
describe('scanHeader include classification', () => {
16+
test('an unguarded include is not cxx-guarded', () => {
17+
const r = scanHeader('#import <React/RCTBridge.h>\n');
18+
expect(r.includes).toEqual([
19+
{token: 'React/RCTBridge.h', cxxGuarded: false},
20+
]);
21+
});
22+
23+
test('an include under #ifdef __cplusplus is cxx-guarded', () => {
24+
const r = scanHeader(
25+
'#ifdef __cplusplus\n#include <folly/dynamic.h>\n#endif\n',
26+
);
27+
expect(r.includes).toEqual([{token: 'folly/dynamic.h', cxxGuarded: true}]);
28+
});
29+
30+
test('#else flips the __cplusplus guard', () => {
31+
const src = [
32+
'#ifdef __cplusplus',
33+
'#include <cpp/only.h>',
34+
'#else',
35+
'#include <c/only.h>',
36+
'#endif',
37+
'',
38+
].join('\n');
39+
expect(scanHeader(src).includes).toEqual([
40+
{token: 'cpp/only.h', cxxGuarded: true},
41+
{token: 'c/only.h', cxxGuarded: false},
42+
]);
43+
});
44+
45+
test('#elif __cplusplus enters a cxx-only region', () => {
46+
const src = [
47+
'#if SOMETHING',
48+
'#include <a.h>',
49+
'#elif __cplusplus',
50+
'#include <b.h>',
51+
'#endif',
52+
'',
53+
].join('\n');
54+
expect(scanHeader(src).includes).toEqual([
55+
{token: 'a.h', cxxGuarded: false},
56+
{token: 'b.h', cxxGuarded: true},
57+
]);
58+
});
59+
});
60+
61+
describe('scanHeader C++ / ObjC surface detection', () => {
62+
test('an unguarded namespace is unguarded C++', () => {
63+
const r = scanHeader('namespace facebook { struct X; }\n');
64+
expect(r.hasUnguardedCxx).toBe(true);
65+
expect(r.hasGuardedCxx).toBe(false);
66+
});
67+
68+
test('a namespace under __cplusplus is guarded, not unguarded', () => {
69+
const r = scanHeader('#ifdef __cplusplus\nnamespace facebook {}\n#endif\n');
70+
expect(r.hasGuardedCxx).toBe(true);
71+
expect(r.hasUnguardedCxx).toBe(false);
72+
});
73+
74+
test('a named aggregate with a member initializer is ObjC++', () => {
75+
const r = scanHeader('struct RCTFontProperties { CGFloat size = NAN; };\n');
76+
expect(r.hasUnguardedCxx).toBe(true);
77+
});
78+
79+
test('an ANONYMOUS aggregate with a member initializer is ObjC++', () => {
80+
// Regression: the tag name is optional, so a typedef'd anonymous struct
81+
// carrying a C++ default member initializer is still detected as ObjC++.
82+
const r = scanHeader('typedef struct { CGFloat x = NAN; } Foo;\n');
83+
expect(r.hasUnguardedCxx).toBe(true);
84+
});
85+
86+
test('@interface marks the header as ObjC', () => {
87+
const r = scanHeader('@interface RCTBridge : NSObject\n@end\n');
88+
expect(r.hasObjC).toBe(true);
89+
});
90+
});
91+
92+
describe('scanHeader comment handling', () => {
93+
test('a multi-line block comment mentioning C++ keywords does not trip the detector', () => {
94+
const src = [
95+
'/*',
96+
' * namespace foo is documented here',
97+
' * template <typename T> and constexpr too',
98+
' */',
99+
'@interface RCTFoo',
100+
'@end',
101+
'',
102+
].join('\n');
103+
const r = scanHeader(src);
104+
expect(r.hasUnguardedCxx).toBe(false);
105+
expect(r.hasGuardedCxx).toBe(false);
106+
expect(r.hasObjC).toBe(true);
107+
});
108+
109+
test('an inline block comment does not trip the detector', () => {
110+
expect(scanHeader('int x; /* namespace y */\n').hasUnguardedCxx).toBe(
111+
false,
112+
);
113+
});
114+
115+
test('a // line comment mentioning a keyword does not trip the detector', () => {
116+
expect(scanHeader('// using namespace std;\n').hasUnguardedCxx).toBe(false);
117+
});
118+
});

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

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,28 @@ const {
2626
renderReactModuleMap,
2727
renderUmbrellaHeader,
2828
} = require('./headers-spec');
29-
const {execSync} = require('child_process');
29+
const {execSync, execFileSync} = require('child_process');
30+
const crypto = require('crypto');
3031
const fs = require('fs');
3132
const path = require('path');
3233

34+
// Hash of the compose tooling itself. Folded into the freshness marker so a
35+
// local edit to any of these scripts (which changes the composed output)
36+
// forces a recompose even when the cached source xcframework is untouched —
37+
// the Info.plist mtime alone can't catch that. Stable across fresh checkouts
38+
// (content-based, not mtime-based). sha256 to avoid weak-hash lint.
39+
function composeToolingHash() /*: string */ {
40+
const hash = crypto.createHash('sha256');
41+
for (const name of [
42+
'headers-inventory.js',
43+
'headers-spec.js',
44+
'headers-compose.js',
45+
]) {
46+
hash.update(fs.readFileSync(path.join(__dirname, name)));
47+
}
48+
return hash.digest('hex');
49+
}
50+
3351
/*:: import type {HeadersSpecPlan, SpecEntry} from './headers-spec'; */
3452

3553
/**
@@ -38,7 +56,20 @@ const path = require('path');
3856
* artifact must not be produced.
3957
*/
4058
function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ {
41-
const plan = planFromInventory(computeInventory(rnRoot), rnRoot);
59+
const inventory = computeInventory(rnRoot);
60+
// R8, part 1: two distinct sources mapping to the same natural Headers/ path.
61+
// addIdentity merges these and planFromInventory keeps only identities[0], so
62+
// the second source would be silently dropped. Fail closed instead.
63+
if (inventory.collisions.length > 0) {
64+
const detail = inventory.collisions
65+
.map(c => `${c.naturalPath} <- ${c.sources.join(', ')}`)
66+
.join('\n ');
67+
throw new Error(
68+
`header-inventory natural-path collisions (R8):\n ${detail}`,
69+
);
70+
}
71+
const plan = planFromInventory(inventory, rnRoot);
72+
// R8, part 2: two natural paths colliding on the same xcframework destination.
4273
if (plan.collisions.length > 0) {
4374
throw new Error(
4475
`headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`,
@@ -106,7 +137,7 @@ function emitReactFrameworkHeaders(
106137
for (const slice of slices) {
107138
const fwk = path.join(xcfwPath, slice, 'React.framework');
108139
fs.rmSync(path.join(fwk, 'Headers'), {recursive: true, force: true});
109-
execSync(`/bin/cp -Rc "${stage}" "${path.join(fwk, 'Headers')}"`);
140+
execFileSync('/bin/cp', ['-Rc', stage, path.join(fwk, 'Headers')]);
110141
fs.rmSync(path.join(fwk, 'Modules'), {recursive: true, force: true});
111142
fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true});
112143
fs.writeFileSync(
@@ -187,7 +218,7 @@ function buildReactNativeHeadersXcframework(
187218
`incomplete ReactNativeHeaders.xcframework.`,
188219
);
189220
}
190-
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`);
221+
execFileSync('/bin/cp', ['-Rc', src, path.join(stage, ns)]);
191222
}
192223
// Set equality with the deps artifact: a namespace dir present in the
193224
// artifact but neither declared for relocation (DEPS_NAMESPACES) nor
@@ -218,10 +249,15 @@ function buildReactNativeHeadersXcframework(
218249
if (hermesHeaders != null) {
219250
const src = path.join(hermesHeaders, 'hermes');
220251
if (fs.existsSync(src)) {
221-
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, 'hermes')}"`);
252+
execFileSync('/bin/cp', ['-Rc', src, path.join(stage, 'hermes')]);
222253
hermesFolded = true;
223254
} else {
224-
console.warn(`headers-compose: hermes headers missing at ${src}`);
255+
console.warn(
256+
`headers-compose: hermes headers missing at ${src} — the composed ` +
257+
'ReactNativeHeaders will NOT resolve <hermes/...>; a downstream ' +
258+
'build that imports Hermes headers will fail. Ensure the hermes-ios ' +
259+
"tarball's destroot/include was staged into the slot.",
260+
);
225261
}
226262
}
227263
// R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h)
@@ -331,8 +367,10 @@ function ensureHeadersLayout(
331367
const sourceStat = fs.statSync(path.join(sourceXcfw, 'Info.plist'));
332368
// Fold the hermes-headers presence into the marker so a slot that gains
333369
// staged hermes headers (e.g. after a tooling upgrade re-downloads them)
334-
// recomposes instead of reusing a hermes-less ReactNativeHeaders.
335-
const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\n`;
370+
// recomposes instead of reusing a hermes-less ReactNativeHeaders. The
371+
// compose-tooling hash makes a local edit to headers-{inventory,spec,compose}
372+
// recompose too (the source xcframework's Info.plist mtime can't detect that).
373+
const marker = `${sourceXcfw}\n${sourceStat.mtimeMs}\n${hermesHeaders ?? 'no-hermes'}\ntooling:${composeToolingHash()}\n`;
336374
if (
337375
!force &&
338376
fs.existsSync(reactXcfw) &&
@@ -349,7 +387,7 @@ function ensureHeadersLayout(
349387
fs.rmSync(reactXcfw, {recursive: true, force: true});
350388
fs.rmSync(markerPath, {force: true});
351389
fs.mkdirSync(outDir, {recursive: true});
352-
execSync(`/bin/cp -Rc "${sourceXcfw}" "${reactXcfw}"`);
390+
execFileSync('/bin/cp', ['-Rc', sourceXcfw, reactXcfw]);
353391
fs.rmSync(path.join(reactXcfw, '_CodeSignature'), {
354392
recursive: true,
355393
force: true,

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

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,30 @@ function scanHeader(text /*: string */) /*: {
158158
const cxxRe =
159159
/^\s*(namespace\s+[A-Za-z_]|template\s*<|extern\s+"C\+\+"|enum\s+class\b|constexpr\b|using\s+(namespace\s|[A-Za-z_]\w*\s*=))/;
160160

161+
// Track /* ... */ block comments across lines so a documentation line inside
162+
// a comment (e.g. `namespace`, `template <`, `constexpr`) can't trip the C++
163+
// detector below and needlessly shrink the umbrella.
164+
let inBlockComment = false;
161165
for (const rawLine of text.split('\n')) {
162-
const line = rawLine.replace(/\/\/.*$/, '');
166+
let line = rawLine;
167+
if (inBlockComment) {
168+
const end = line.indexOf('*/');
169+
if (end === -1) {
170+
continue; // whole line still inside a block comment
171+
}
172+
line = line.slice(end + 2);
173+
inBlockComment = false;
174+
}
175+
// Drop complete inline block comments, then line comments (which also
176+
// swallow any `/*` living inside a `//` comment), then detect a block
177+
// comment that opens and runs onto the next line.
178+
line = line.replace(/\/\*.*?\*\//g, '');
179+
line = line.replace(/\/\/.*$/, '');
180+
const blockOpen = line.indexOf('/*');
181+
if (blockOpen !== -1) {
182+
inBlockComment = true;
183+
line = line.slice(0, blockOpen);
184+
}
163185
const cond = line.match(/^\s*#\s*(if|ifdef|ifndef|elif|else|endif)\b(.*)$/);
164186
if (cond) {
165187
const [, directive, rest] = cond;
@@ -214,9 +236,11 @@ function scanHeader(text /*: string */) /*: {
214236
// declaration carrying an `=` initializer. Whole-text (not per-line) so the
215237
// aggregate context is required, avoiding false positives on file-scope
216238
// definitions. Unguarded by construction (definitions can't sit under a
217-
// pure `#ifdef __cplusplus` and still be the ObjC surface).
239+
// pure `#ifdef __cplusplus` and still be the ObjC surface). The tag name is
240+
// optional so an anonymous `typedef struct { CGFloat x = NAN; } Foo;` is
241+
// caught too (a named aggregate is not required for the ObjC++ surface).
218242
const aggregateMemberInitRe =
219-
/\b(?:struct|class)\s+[A-Za-z_]\w*[^;{}]*\{[^{}]*?\b[A-Za-z_][\w\s:<>,]*\**\s+\*?[A-Za-z_]\w*\s*=\s*[^;{}]+;/s;
243+
/\b(?:struct|class)\b(?:\s+[A-Za-z_]\w*)?[^;{}]*\{[^{}]*?\b[A-Za-z_][\w\s:<>,]*\**\s+\*?[A-Za-z_]\w*\s*=\s*[^;{}]+;/s;
220244
if (aggregateMemberInitRe.test(text)) {
221245
hasUnguardedCxx = true;
222246
}
@@ -325,7 +349,7 @@ function buildInventory(rootFolder /*: string */) /*: {
325349
const headerMaps = podSpecsWithHeaderFiles[podspecPath];
326350
// xcframework.js and vfs.js both use the ROOT spec's name (first map) as
327351
// the pod folder, with the same first-occurrence '-' -> '_' replacement.
328-
const podName = headerMaps[0].specName.replace('-', '_');
352+
const podName = headerMaps[0].specName.replace(/-/g, '_');
329353

330354
for (const headerMap of headerMaps) {
331355
for (const header of headerMap.headers) {
@@ -578,15 +602,21 @@ if (require.main === module) {
578602
* without going through the JSON manifest on disk (e.g. the prebuild compose
579603
* step feeding headers-spec.planFromInventory).
580604
*/
581-
function computeInventory(
582-
rootFolder /*: string */,
583-
) /*: {headers: Array<HeaderEntry>} */ {
584-
const {entries, sourceToNatural} = buildInventory(rootFolder);
605+
function computeInventory(rootFolder /*: string */) /*: {
606+
headers: Array<HeaderEntry>,
607+
collisions: Array<{naturalPath: string, sources: Array<string>}>,
608+
} */ {
609+
const {entries, sourceToNatural, collisions} = buildInventory(rootFolder);
585610
classifyEntries(entries, sourceToNatural, rootFolder);
586611
return {
587612
headers: Array.from(entries.values()).sort((a, b) =>
588613
a.naturalPath.localeCompare(b.naturalPath),
589614
),
615+
// Natural-path collisions (two distinct sources mapping to the same
616+
// Headers/ path) are silently merged by addIdentity — planFromInventory
617+
// only keeps identities[0].source, so the second source is dropped. Surface
618+
// them so the compose gate can fail closed (R8) instead of regressing.
619+
collisions,
590620
};
591621
}
592622

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@
5656
* header manifest).
5757
* R8. Collisions are ERRORS: two different source files may never project to
5858
* the same destination path.
59+
* R9. Private React headers — a curated allowlist of `<React/...>` headers the
60+
* umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked) — are
61+
* added to the React framework module map so privileged consumers can reach
62+
* them. The allowlist fails closed on drift (validatePrivateReactHeaders).
63+
* R10. Per-namespace umbrella headers are emitted into ReactNativeHeaders so
64+
* consumers that import a whole namespace (e.g. Expo) get one entry point;
65+
* each is derived from namespaceModules (R5) so it cannot drift.
5966
* R11. ONE source file, ONE content location. Some sources ship under several
6067
* spellings (React/X.h + a legacy pod-namespace form like CoreModules/X.h,
6168
* or a bare root alias + React_RCTAppDelegate/X.h). Under the VFS overlay

packages/react-native/scripts/ios-prebuild/xcframework.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const childProcess = require('child_process');
1818
const fs = require('fs');
1919
const path = require('path');
2020

21-
const {execSync} = childProcess;
21+
const {execSync, execFileSync} = childProcess;
2222
const {createLogger} = utils;
2323

2424
const frameworkLog = createLogger('XCFramework');
@@ -125,11 +125,17 @@ function buildXCFrameworks(
125125
// reactnative-core artifact so the React-Core-prebuilt pod can vend both
126126
// (React.framework -> <React/...>, ReactNativeHeaders -> every other
127127
// namespace). The headers-only xcframework is a sibling of React.xcframework.
128-
execSync(
129-
`tar -czf ${tarFilePath} -C ${path.dirname(outputPath)} React.xcframework ${path.basename(headersXcfw)}`,
130-
{
131-
stdio: 'inherit',
132-
},
128+
execFileSync(
129+
'tar',
130+
[
131+
'-czf',
132+
tarFilePath,
133+
'-C',
134+
path.dirname(outputPath),
135+
'React.xcframework',
136+
path.basename(headersXcfw),
137+
],
138+
{stdio: 'inherit'},
133139
);
134140
} catch (error) {
135141
frameworkLog(
@@ -148,8 +154,15 @@ function buildXCFrameworks(
148154
);
149155
frameworkLog('Creating tar file: ' + headersTarPath);
150156
try {
151-
execSync(
152-
`tar -czf ${headersTarPath} -C ${path.dirname(headersXcfw)} ReactNativeHeaders.xcframework`,
157+
execFileSync(
158+
'tar',
159+
[
160+
'-czf',
161+
headersTarPath,
162+
'-C',
163+
path.dirname(headersXcfw),
164+
'ReactNativeHeaders.xcframework',
165+
],
153166
{stdio: 'inherit'},
154167
);
155168
} catch (error) {

0 commit comments

Comments
 (0)