Skip to content

Commit 76740fc

Browse files
module: add getRootPackageJSON
1 parent a50f611 commit 76740fc

13 files changed

Lines changed: 205 additions & 13 deletions

File tree

β€Žlib/internal/modules/package_json_reader.jsβ€Ž

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,40 @@ function getNearestParentPackageJSON(checkPath) {
186186
return packageConfig;
187187
}
188188

189+
const moduleToRootPackageJSONCache = new SafeMap();
190+
191+
/**
192+
* This is stricter than {@link getNearestParentPackageJSON}: nested
193+
* `package.json` files inside a package are ignored, and a non-private package
194+
* anywhere in the `node_modules` chain (for example a publishable package that
195+
* bundles a private dependency) disqualifies the path. A directory under
196+
* `node_modules` with no `package.json` (such as pnpm's `.pnpm` store) is not
197+
* a package and is skipped.
198+
*
199+
* Returns the package.json data and the path to the package.json file, or undefined.
200+
* @param {string} checkPath The path to start searching from.
201+
* @returns {undefined | DeserializedPackageConfig}
202+
*/
203+
function getRootPackageJSON(checkPath) {
204+
const packageJSONPath = moduleToRootPackageJSONCache.get(checkPath);
205+
if (packageJSONPath !== undefined) {
206+
return deserializedPackageJSONCache.get(packageJSONPath);
207+
}
208+
209+
const result = modulesBinding.getRootPackageJSON(checkPath);
210+
const packageConfig = deserializePackageJSON(checkPath, result);
211+
212+
moduleToRootPackageJSONCache.set(checkPath, packageConfig.path);
213+
214+
const maybeCachedPackageConfig = deserializedPackageJSONCache.get(packageConfig.path);
215+
if (maybeCachedPackageConfig !== undefined) {
216+
return maybeCachedPackageConfig;
217+
}
218+
219+
deserializedPackageJSONCache.set(packageConfig.path, packageConfig);
220+
return packageConfig;
221+
}
222+
189223
/**
190224
* Returns the package configuration for the given resolved URL.
191225
* @param {URL | string} resolved - The resolved URL.
@@ -358,6 +392,7 @@ function findPackageJSON(specifier, base = 'data:') {
358392
module.exports = {
359393
read,
360394
getNearestParentPackageJSON,
395+
getRootPackageJSON,
361396
getPackageScopeConfig,
362397
getPackageType,
363398
getPackageJSONURL,

β€Žlib/internal/modules/typescript.jsβ€Ž

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,15 +152,22 @@ const isStripPrivateModulesEnabled = getLazy(
152152
);
153153

154154
/**
155-
* Checks if the nearest parent package.json of a file marks its package
156-
* as private.
155+
* Checks whether a file under node_modules is owned by a private package, in
156+
* which case type stripping is allowed under `--experimental-strip-private-modules`.
157+
*
158+
* Every package enclosing the file inside `node_modules` must be marked
159+
* `"private": true`. npm refuses to publish a private package, so requiring
160+
* the package root (not a nested package.json, which is published verbatim)
161+
* and rejecting any non-private enclosing package (e.g. a publishable package
162+
* bundling a private dependency) prevents published packages from shipping
163+
* strippable TypeScript. See {@link getRootPackageJSON}.
157164
* @param {string} filename The filename (path or file URL) of the source code.
158165
* @returns {boolean} Whether the file belongs to a package with `"private": true`.
159166
*/
160167
function isInsidePrivatePackage(filename) {
161-
const { getNearestParentPackageJSON } = require('internal/modules/package_json_reader');
168+
const { getRootPackageJSON } = require('internal/modules/package_json_reader');
162169
const { urlToFilename } = require('internal/modules/helpers');
163-
const packageJSON = getNearestParentPackageJSON(urlToFilename(filename));
170+
const packageJSON = getRootPackageJSON(urlToFilename(filename));
164171
return packageJSON?.data.private === true;
165172
}
166173

β€Žsrc/node_modules.ccβ€Ž

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,72 @@ const BindingData::PackageConfig* BindingData::TraverseParent(
342342
return nullptr;
343343
}
344344

345+
const BindingData::PackageConfig* BindingData::FindNodeModulesPackage(
346+
Realm* realm, const std::filesystem::path& check_path) {
347+
std::filesystem::path current_path = check_path;
348+
std::filesystem::path child;
349+
std::filesystem::path grandchild;
350+
const PackageConfig* innermost = nullptr;
351+
auto env = realm->env();
352+
const bool is_permissions_enabled = env->permission()->enabled();
353+
354+
while (true) {
355+
auto parent_path = current_path.parent_path();
356+
if (parent_path == current_path) {
357+
// Reached the filesystem root.
358+
break;
359+
}
360+
361+
if (current_path.filename() == "node_modules") {
362+
// `child` is the directory directly under node_modules. For scoped
363+
// packages the package root is one level further down.
364+
const std::string child_name = child.filename().generic_string();
365+
const std::filesystem::path& package_root =
366+
(!child_name.empty() && child_name[0] == '@') ? grandchild : child;
367+
if (package_root.empty()) {
368+
// The path points at a node_modules or bare scope directory rather
369+
// than into a package.
370+
return nullptr;
371+
}
372+
373+
auto package_json_path = package_root / "package.json";
374+
375+
// GetPackageJSON()'s ReadFileSync() reads the file directly without
376+
// consulting the permission model, so check read access here as
377+
// TraverseParent() does. Without permission to confirm the package is
378+
// private, fail closed and deny stripping.
379+
if (is_permissions_enabled) [[unlikely]] {
380+
if (!env->permission()->is_granted(
381+
env,
382+
permission::PermissionScope::kFileSystemRead,
383+
ConvertGenericPathToUTF8(package_json_path))) {
384+
return nullptr;
385+
}
386+
}
387+
388+
auto package_json =
389+
GetPackageJSON(realm, ConvertPathToUTF8(package_json_path), nullptr);
390+
if (package_json != nullptr) {
391+
if (!package_json->is_private.value_or(false)) {
392+
// A publishable (non-private) package encloses the file.
393+
return nullptr;
394+
}
395+
if (innermost == nullptr) {
396+
innermost = package_json;
397+
}
398+
}
399+
// A directory under node_modules with no package.json is not a package
400+
// (e.g. pnpm's `.pnpm` store); skip it and keep walking upwards.
401+
}
402+
403+
grandchild = child;
404+
child = current_path;
405+
current_path = parent_path;
406+
}
407+
408+
return innermost;
409+
}
410+
345411
const std::filesystem::path BindingData::NormalizePath(
346412
Realm* realm, BufferValue* path_value) {
347413
// Check if the path has a trailing slash. If so, add it after
@@ -376,6 +442,22 @@ void BindingData::GetNearestParentPackageJSON(
376442
}
377443
}
378444

445+
void BindingData::GetRootPackageJSON(const FunctionCallbackInfo<Value>& args) {
446+
CHECK_GE(args.Length(), 1);
447+
CHECK(args[0]->IsString());
448+
449+
Realm* realm = Realm::GetCurrent(args);
450+
BufferValue path_value(realm->isolate(), args[0]);
451+
452+
auto path = NormalizePath(realm, &path_value);
453+
454+
auto package_json = FindNodeModulesPackage(realm, path);
455+
456+
if (package_json != nullptr) {
457+
args.GetReturnValue().Set(package_json->Serialize(realm));
458+
}
459+
}
460+
379461
void BindingData::GetNearestParentPackageJSONType(
380462
const FunctionCallbackInfo<Value>& args) {
381463
CHECK_GE(args.Length(), 1);
@@ -629,6 +711,7 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
629711
target,
630712
"getNearestParentPackageJSON",
631713
GetNearestParentPackageJSON);
714+
SetMethod(isolate, target, "getRootPackageJSON", GetRootPackageJSON);
632715
SetMethod(
633716
isolate, target, "getPackageScopeConfig", GetPackageScopeConfig<false>);
634717
SetMethod(isolate, target, "getPackageType", GetPackageScopeConfig<true>);
@@ -701,6 +784,7 @@ void BindingData::RegisterExternalReferences(
701784
registry->Register(ReadPackageJSON);
702785
registry->Register(GetNearestParentPackageJSONType);
703786
registry->Register(GetNearestParentPackageJSON);
787+
registry->Register(GetRootPackageJSON);
704788
registry->Register(GetPackageScopeConfig<false>);
705789
registry->Register(GetPackageScopeConfig<true>);
706790
registry->Register(EnableCompileCache);

β€Žsrc/node_modules.hβ€Ž

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class BindingData : public SnapshotableObject {
5858
static void ReadPackageJSON(const v8::FunctionCallbackInfo<v8::Value>& args);
5959
static void GetNearestParentPackageJSON(
6060
const v8::FunctionCallbackInfo<v8::Value>& args);
61+
static void GetRootPackageJSON(
62+
const v8::FunctionCallbackInfo<v8::Value>& args);
6163
static void GetNearestParentPackageJSONType(
6264
const v8::FunctionCallbackInfo<v8::Value>& args);
6365
template <bool return_only_type>
@@ -93,6 +95,11 @@ class BindingData : public SnapshotableObject {
9395
ErrorContext* error_context = nullptr);
9496
static const PackageConfig* TraverseParent(
9597
Realm* realm, const std::filesystem::path& check_path);
98+
// Returns the package.json of the package that owns check_path inside the
99+
// innermost node_modules directory, or null when check_path is not inside
100+
// node_modules or the package root has no package.json.
101+
static const PackageConfig* FindNodeModulesPackage(
102+
Realm* realm, const std::filesystem::path& check_path);
96103
};
97104

98105
} // namespace modules

β€Žtest/es-module/test-typescript.mjsβ€Ž

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,22 +109,60 @@ test('expect failure for a private package in node_modules without --experimenta
109109
assert.strictEqual(result.code, 1);
110110
});
111111

112-
// TODO(marco-ippolito): a nested package.json must not be able to enable type
113-
// stripping: npm only refuses to publish packages whose root package.json has
114-
// "private": true, so a published package can freely ship a nested
115-
// package.json with "private": true and bypass the restriction. Only the
116-
// package.json at the package root should be consulted.
117-
test('nested package.json faking "private": true in node_modules enables type stripping',
112+
// A nested package.json must not be able to enable type stripping: npm only
113+
// refuses to publish packages whose root package.json has "private": true, so
114+
// a published package can freely ship a nested package.json with
115+
// "private": true. Only the package.json at the package root is consulted.
116+
test('expect failure for a nested package.json faking "private": true in node_modules',
118117
async () => {
119118
const result = await spawnPromisified(process.execPath, [
120119
'--experimental-strip-private-modules',
121120
'--no-warnings',
122121
fixtures.path('typescript/ts/test-typescript-fake-private-node-modules.ts'),
123122
]);
124123

125-
assert.strictEqual(result.stderr, '');
126-
assert.match(result.stdout, /Hello, TypeScript!/);
127-
assert.strictEqual(result.code, 0);
124+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
125+
assert.strictEqual(result.stdout, '');
126+
assert.strictEqual(result.code, 1);
127+
});
128+
129+
test('expect failure for a private package bundled inside a non-private package',
130+
async () => {
131+
const result = await spawnPromisified(process.execPath, [
132+
'--experimental-strip-private-modules',
133+
'--no-warnings',
134+
fixtures.path('typescript/ts/test-typescript-bundled-private-node-modules.ts'),
135+
]);
136+
137+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
138+
assert.strictEqual(result.stdout, '');
139+
assert.strictEqual(result.code, 1);
140+
});
141+
142+
test('expect failure for a relative import into a non-private node_modules package',
143+
async () => {
144+
const result = await spawnPromisified(process.execPath, [
145+
'--experimental-strip-private-modules',
146+
'--no-warnings',
147+
fixtures.path('typescript/ts/test-typescript-relative-node-modules.ts'),
148+
]);
149+
150+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
151+
assert.strictEqual(result.stdout, '');
152+
assert.strictEqual(result.code, 1);
153+
});
154+
155+
test('expect failure for a ".." path resolving to a non-private node_modules package',
156+
async () => {
157+
const result = await spawnPromisified(process.execPath, [
158+
'--experimental-strip-private-modules',
159+
'--no-warnings',
160+
fixtures.path('typescript/ts/test-typescript-dotdot-node-modules.ts'),
161+
]);
162+
163+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
164+
assert.strictEqual(result.stdout, '');
165+
assert.strictEqual(result.code, 1);
128166
});
129167

130168
test('expect failure for a non-private package in node_modules with --experimental-strip-private-modules',

β€Žtest/fixtures/typescript/ts/node_modules/bundler-pkg/index.jsβ€Ž

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/bundled-private.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/typescript/ts/node_modules/bundler-pkg/node_modules/bundled-private/package.jsonβ€Ž

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

β€Žtest/fixtures/typescript/ts/node_modules/bundler-pkg/package.jsonβ€Ž

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { baz } from 'bundler-pkg';
2+
3+
console.log(baz);

0 commit comments

Comments
Β (0)