Skip to content

Commit ac9e626

Browse files
module: add getRootPackageJSON
1 parent 033c9c7 commit ac9e626

13 files changed

Lines changed: 209 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: 88 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,23 @@ void BindingData::GetNearestParentPackageJSON(
376442
}
377443
}
378444

445+
void BindingData::GetRootPackageJSON(
446+
const FunctionCallbackInfo<Value>& args) {
447+
CHECK_GE(args.Length(), 1);
448+
CHECK(args[0]->IsString());
449+
450+
Realm* realm = Realm::GetCurrent(args);
451+
BufferValue path_value(realm->isolate(), args[0]);
452+
453+
auto path = NormalizePath(realm, &path_value);
454+
455+
auto package_json = FindNodeModulesPackage(realm, path);
456+
457+
if (package_json != nullptr) {
458+
args.GetReturnValue().Set(package_json->Serialize(realm));
459+
}
460+
}
461+
379462
void BindingData::GetNearestParentPackageJSONType(
380463
const FunctionCallbackInfo<Value>& args) {
381464
CHECK_GE(args.Length(), 1);
@@ -629,6 +712,10 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
629712
target,
630713
"getNearestParentPackageJSON",
631714
GetNearestParentPackageJSON);
715+
SetMethod(isolate,
716+
target,
717+
"getRootPackageJSON",
718+
GetRootPackageJSON);
632719
SetMethod(
633720
isolate, target, "getPackageScopeConfig", GetPackageScopeConfig<false>);
634721
SetMethod(isolate, target, "getPackageType", GetPackageScopeConfig<true>);
@@ -701,6 +788,7 @@ void BindingData::RegisterExternalReferences(
701788
registry->Register(ReadPackageJSON);
702789
registry->Register(GetNearestParentPackageJSONType);
703790
registry->Register(GetNearestParentPackageJSON);
791+
registry->Register(GetRootPackageJSON);
704792
registry->Register(GetPackageScopeConfig<false>);
705793
registry->Register(GetPackageScopeConfig<true>);
706794
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
@@ -97,22 +97,60 @@ test('expect failure for a private package in node_modules without --experimenta
9797
assert.strictEqual(result.code, 1);
9898
});
9999

100-
// TODO(marco-ippolito): a nested package.json must not be able to enable type
101-
// stripping: npm only refuses to publish packages whose root package.json has
102-
// "private": true, so a published package can freely ship a nested
103-
// package.json with "private": true and bypass the restriction. Only the
104-
// package.json at the package root should be consulted.
105-
test('nested package.json faking "private": true in node_modules enables type stripping',
100+
// A nested package.json must not be able to enable type stripping: npm only
101+
// refuses to publish packages whose root package.json has "private": true, so
102+
// a published package can freely ship a nested package.json with
103+
// "private": true. Only the package.json at the package root is consulted.
104+
test('expect failure for a nested package.json faking "private": true in node_modules',
106105
async () => {
107106
const result = await spawnPromisified(process.execPath, [
108107
'--experimental-strip-private-modules',
109108
'--no-warnings',
110109
fixtures.path('typescript/ts/test-typescript-fake-private-node-modules.ts'),
111110
]);
112111

113-
assert.strictEqual(result.stderr, '');
114-
assert.match(result.stdout, /Hello, TypeScript!/);
115-
assert.strictEqual(result.code, 0);
112+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
113+
assert.strictEqual(result.stdout, '');
114+
assert.strictEqual(result.code, 1);
115+
});
116+
117+
test('expect failure for a private package bundled inside a non-private package',
118+
async () => {
119+
const result = await spawnPromisified(process.execPath, [
120+
'--experimental-strip-private-modules',
121+
'--no-warnings',
122+
fixtures.path('typescript/ts/test-typescript-bundled-private-node-modules.ts'),
123+
]);
124+
125+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
126+
assert.strictEqual(result.stdout, '');
127+
assert.strictEqual(result.code, 1);
128+
});
129+
130+
test('expect failure for a relative import into a non-private node_modules package',
131+
async () => {
132+
const result = await spawnPromisified(process.execPath, [
133+
'--experimental-strip-private-modules',
134+
'--no-warnings',
135+
fixtures.path('typescript/ts/test-typescript-relative-node-modules.ts'),
136+
]);
137+
138+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
139+
assert.strictEqual(result.stdout, '');
140+
assert.strictEqual(result.code, 1);
141+
});
142+
143+
test('expect failure for a ".." path resolving to a non-private node_modules package',
144+
async () => {
145+
const result = await spawnPromisified(process.execPath, [
146+
'--experimental-strip-private-modules',
147+
'--no-warnings',
148+
fixtures.path('typescript/ts/test-typescript-dotdot-node-modules.ts'),
149+
]);
150+
151+
assert.match(result.stderr, /ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
152+
assert.strictEqual(result.stdout, '');
153+
assert.strictEqual(result.code, 1);
116154
});
117155

118156
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)