diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index ea32e11dbc0..8736964a712 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -1179,6 +1179,137 @@ describe('main() — autolinking plugin host exemption', () => { ); }); +// --------------------------------------------------------------------------- +// main() — spm.modules name validation +// +// App-local module names land in the manifest exactly as written, so they need +// the checks an autolinked dep's Swift name gets: a valid identifier, not one +// of React Native's reserved names, and unique across modules and deps. +// --------------------------------------------------------------------------- + +describe('main() — spm.modules names', () => { + let created = []; + let spies = []; + + beforeEach(() => { + for (const m of ['log', 'warn', 'error']) { + spies.push(jest.spyOn(console, m).mockImplementation(() => {})); + } + }); + + afterEach(() => { + for (const s of spies) s.mockRestore(); + spies = []; + for (const d of created) fs.rmSync(d, {recursive: true, force: true}); + created = []; + }); + + // App fixture whose react-native.config.js declares `spm.modules`, plus an + // optional autolinked dep (for the module-vs-dep collision case). + function buildApp({modules, dep}) { + const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-modules-')); + created.push(appRoot); + const rnRoot = path.join(appRoot, 'rn'); + fs.mkdirSync(rnRoot, {recursive: true}); + fs.writeFileSync( + path.join(appRoot, 'package.json'), + JSON.stringify({name: 'app'}), + ); + for (const mod of modules) { + const modDir = path.join(appRoot, mod.path); + fs.mkdirSync(modDir, {recursive: true}); + fs.writeFileSync(path.join(modDir, 'Module.mm'), '// native source\n'); + } + fs.writeFileSync( + path.join(appRoot, 'react-native.config.js'), + `module.exports = ${JSON.stringify({spm: {modules}})};\n`, + ); + const dependencies = {}; + if (dep != null) { + const depDir = path.join(appRoot, 'node_modules', dep.name); + fs.mkdirSync(path.join(depDir, 'ios'), {recursive: true}); + fs.writeFileSync( + path.join(depDir, 'ios', 'Dep.mm'), + '// native source\n', + ); + fs.writeFileSync( + path.join(depDir, 'Package.swift'), + '// swift-tools-version: 6.0\n', + ); + dependencies[dep.name] = {root: depDir, platforms: {ios: {}}}; + } + const autolinkDir = path.join(appRoot, 'build', 'generated', 'autolinking'); + fs.mkdirSync(autolinkDir, {recursive: true}); + fs.writeFileSync( + path.join(autolinkDir, 'autolinking.json'), + JSON.stringify({dependencies}), + ); + return {appRoot, rnRoot}; + } + + const run = ({appRoot, rnRoot}) => + main(['--app-root', appRoot, '--react-native-root', rnRoot]); + + it('accepts a normal module name', () => { + const app = buildApp({ + modules: [{name: 'MyNativeModule', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).not.toThrow(); + }); + + it('rejects a module named after a reserved React Native name', () => { + const app = buildApp({ + modules: [{name: 'ReactNative', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNative' resolves to 'ReactNative', which React Native reserves/, + ); + expect(() => run(app)).toThrow(/'spm\.modules'\.$/); + }); + + it('rejects a reserved product name in any casing', () => { + const app = buildApp({ + modules: [{name: 'reactheaders', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm\.modules' entry 'reactheaders' resolves to 'reactheaders', which differs from React Native's reserved 'ReactHeaders' only in case/, + ); + }); + + it('rejects a module name that is not a valid Swift identifier', () => { + const app = buildApp({ + modules: [{name: 'My Module', path: 'ios/MyNativeModule'}], + }); + expect(() => run(app)).toThrow(/invalid 'spm.modules' name "My Module"/); + }); + + it('rejects two modules resolving to the same name', () => { + const app = buildApp({ + modules: [ + {name: 'Shared', path: 'ios/one'}, + {name: 'shared', path: 'ios/two'}, + ], + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'shared' differs from the existing target 'Shared' only in case/, + ); + }); + + it('rejects a module colliding with an autolinked dep', () => { + const app = buildApp({ + modules: [{name: 'ReactNativeFoo', path: 'ios/MyNativeModule'}], + dep: {name: 'react-native-foo'}, + }); + expect(() => run(app)).toThrow(SpmNameCollisionError); + expect(() => run(app)).toThrow( + /the 'spm.modules' entry 'ReactNativeFoo' is already the name of another autolinked target/, + ); + }); +}); + // --------------------------------------------------------------------------- // main() — plugin flavoredFrameworks sidecar // diff --git a/packages/react-native/scripts/spm/expand-spm-dependencies.js b/packages/react-native/scripts/spm/expand-spm-dependencies.js index dead1f9bb11..4bee4044797 100644 --- a/packages/react-native/scripts/spm/expand-spm-dependencies.js +++ b/packages/react-native/scripts/spm/expand-spm-dependencies.js @@ -71,6 +71,7 @@ class SpmNameCollisionError extends Error { // The charset `spm.name` must satisfy — permissive on purpose, since it has to // admit header-dir style (lowercase with hyphens) as well as Swift identifiers. +// Shared with the app's own `spm.modules` names. function isValidSwiftName(name /*: unknown */) /*: boolean */ { return typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(name); } diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 3cdee1d2645..4d21ebeb0fc 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -59,9 +59,12 @@ const {discoverPlugins, invokePlugins} = require('./autolinking-plugins'); const { + SpmNameCollisionError, + assertSwiftNameNotReserved, defaultReadConfig, defaultResolveDep, expandSpmDependencies, + isValidSwiftName, } = require('./expand-spm-dependencies'); const {readPodspec} = require('./read-podspec'); const { @@ -265,6 +268,41 @@ function readSpmModulesFromConfig( } } +/** + * Validates one app-local `spm.modules` name against the same rules a library's + * `spm.name` gets: a usable Swift identifier, not a name React Native reserves, + * and not one already taken by another module or an autolinked dep. + * `taken` maps lower-cased name → the name as written. + */ +function assertSpmModuleName( + name /*: unknown */, + taken /*: Map */, +) /*: void */ { + const remedy = + "Rename it in this app's react-native.config.js 'spm.modules'."; + if (typeof name !== 'string' || !isValidSwiftName(name)) { + throw new Error( + `react-native autolinking: invalid 'spm.modules' name ${JSON.stringify(name) ?? 'undefined'}: must start with a letter or underscore and contain only letters, digits, underscores, or hyphens.`, + ); + } + const moduleName = name; + assertSwiftNameNotReserved(moduleName, { + label: `the 'spm.modules' entry '${moduleName}'`, + remedy, + extraReservedNames: reservedNamesForRun(), + }); + const clash = taken.get(moduleName.toLowerCase()); + if (clash != null) { + throw new SpmNameCollisionError( + `react-native autolinking: SPM Swift name collision: the 'spm.modules' entry '${moduleName}' ` + + (clash === moduleName + ? `is already the name of another autolinked target.` + : `differs from the existing target '${clash}' only in case, which collides on case-insensitive filesystems.`) + + ` ${remedy}`, + ); + } +} + /** * Reads the app's `spm.denyPlugins` — npm names of autolinking plugins to * skip. The escape hatch for the transitive plugin discovery (an app opts a @@ -1347,7 +1385,15 @@ function main(argv /*:: ?: Array */) /*: void */ { // the globs now relative to its dir and attach the file list to the target // so the emission loop below renders `sources: [...]` literally. const configModules = readSpmModulesFromConfig(appRoot); + // Module names land in the manifest exactly as written, so they get the same + // checks a dep's Swift name gets. Seeded with the dep target names already + // emitted so a module can't shadow an autolinked library either. + const takenSwiftNames /*: Map */ = new Map( + entries.map(entry => [entry.target.name.toLowerCase(), entry.target.name]), + ); for (const mod of configModules) { + assertSpmModuleName(mod.name, takenSwiftNames); + takenSwiftNames.set(mod.name.toLowerCase(), mod.name); const absPath = path.resolve(appRoot, mod.path); const relPath = path.relative(outputDir, absPath); const userSources =