Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
46 changes: 46 additions & 0 deletions packages/react-native/scripts/spm/generate-spm-autolinking.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> */,
) /*: 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
Expand Down Expand Up @@ -1347,7 +1385,15 @@ function main(argv /*:: ?: Array<string> */) /*: 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<string, string> */ = 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 =
Expand Down
Loading