Skip to content

Commit 18c6e80

Browse files
authored
feat: add strict_visibility to npm_install / yarn_install rules (#2193)
With strict_visibility enabled (currently defaults false), only dependencies within the given `package.json` file are given public visibility. All transitive dependencies are given limited visibility, enforcing that all direct dependencies are listed in the `package.json` file. closes #2110
1 parent 3639df1 commit 18c6e80

File tree

8 files changed

+139
-43
lines changed

8 files changed

+139
-43
lines changed

examples/from_source/WORKSPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ node_repositories(
4040
yarn_install(
4141
name = "npm",
4242
package_json = "//:package.json",
43+
strict_visibility = True,
4344
yarn_lock = "//:yarn.lock",
4445
)

internal/npm_install/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ load("//packages/typescript:checked_in_ts_project.bzl", "checked_in_ts_project")
77

88
# Using checked in ts library like the linker
99
# To update index.js run:
10-
# bazel run //internal/npm_install:compile_generate_build_file_check_compiled.accept
10+
# bazel run //internal/npm_install:compile_generate_build_file_check_compiled.update
1111
checked_in_ts_project(
1212
name = "compile_generate_build_file",
1313
src = "generate_build_file.ts",

internal/npm_install/generate_build_file.ts

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,26 @@ function log_verbose(...m: any[]) {
4848
if (!!process.env['VERBOSE_LOGS']) console.error('[generate_build_file.ts]', ...m);
4949
}
5050

51-
const BUILD_FILE_HEADER = `# Generated file from yarn_install/npm_install rule.
52-
# See rules_nodejs/internal/npm_install/generate_build_file.ts
53-
54-
# All rules in other repositories can use these targets
55-
package(default_visibility = ["//visibility:public"])
56-
57-
`
58-
5951
const args = process.argv.slice(2);
6052
const WORKSPACE = args[0];
6153
const RULE_TYPE = args[1];
62-
const LOCK_FILE_PATH = args[2];
63-
const INCLUDED_FILES = args[3] ? args[3].split(',') : [];
64-
const BAZEL_VERSION = args[4];
54+
const PKG_JSON_FILE_PATH = args[2];
55+
const LOCK_FILE_PATH = args[3];
56+
const STRICT_VISIBILITY = args[4]?.toLowerCase() === 'true';
57+
const INCLUDED_FILES = args[5] ? args[5].split(',') : [];
58+
const BAZEL_VERSION = args[6];
59+
60+
const PUBLIC_VISIBILITY = '//visibility:public';
61+
const LIMITED_VISIBILITY = `@${WORKSPACE}//:__subpackages__`;
62+
63+
function generateBuildFileHeader(visibility = PUBLIC_VISIBILITY): string {
64+
return `# Generated file from ${RULE_TYPE} rule.
65+
# See rules_nodejs/internal/npm_install/generate_build_file.ts
66+
67+
package(default_visibility = ["${visibility}"])
68+
69+
`;
70+
}
6571

6672
if (require.main === module) {
6773
main();
@@ -91,8 +97,11 @@ function writeFileSync(p: string, content: string) {
9197
* Main entrypoint.
9298
*/
9399
export function main() {
100+
// get a set of all the direct dependencies for visibility
101+
const deps = getDirectDependencySet(PKG_JSON_FILE_PATH);
102+
94103
// find all packages (including packages in nested node_modules)
95-
const pkgs = findPackages();
104+
const pkgs = findPackages('node_modules', deps);
96105

97106
// flatten dependencies
98107
flattenDependencies(pkgs);
@@ -157,7 +166,7 @@ function generateRootBuildFile(pkgs: Dep[]) {
157166
`;
158167
})});
159168

160-
let buildFile = BUILD_FILE_HEADER + `load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
169+
let buildFile = generateBuildFileHeader() + `load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
161170
162171
exports_files([
163172
${exportsStarlark}])
@@ -198,12 +207,18 @@ function generatePackageBuildFiles(pkg: Dep) {
198207
buildFilePath = 'BUILD.bazel'
199208
}
200209

210+
// if the dependency doesn't appear in the given package.json file, and the 'strict_visibility' flag is set
211+
// on the npm_install / yarn_install rule, then set the visibility to be limited internally to the @repo workspace
212+
// if the dependency is listed, set it as public
213+
// if the flag is false, then always set public visibility
214+
const visibility = !pkg._directDependency && STRICT_VISIBILITY ? LIMITED_VISIBILITY : PUBLIC_VISIBILITY;
215+
201216
// If the package didn't ship a bin/BUILD file, generate one.
202217
if (!pkg._files.includes('bin/BUILD.bazel') && !pkg._files.includes('bin/BUILD')) {
203218
const binBuildFile = printPackageBin(pkg);
204219
if (binBuildFile.length) {
205220
writeFileSync(
206-
path.posix.join(pkg._dir, 'bin', 'BUILD.bazel'), BUILD_FILE_HEADER + binBuildFile);
221+
path.posix.join(pkg._dir, 'bin', 'BUILD.bazel'), generateBuildFileHeader(visibility) + binBuildFile);
207222
}
208223
}
209224

@@ -241,7 +256,7 @@ exports_files(["index.bzl"])
241256
}
242257
}
243258

244-
writeFileSync(path.posix.join(pkg._dir, buildFilePath), BUILD_FILE_HEADER + buildFile);
259+
writeFileSync(path.posix.join(pkg._dir, buildFilePath), generateBuildFileHeader(visibility) + buildFile);
245260
}
246261

247262
/**
@@ -389,7 +404,7 @@ You can suppress this message by passing "suppress_warning = True" to install_ba
389404
* Generate build files for a scope.
390405
*/
391406
function generateScopeBuildFiles(scope: string, pkgs: Dep[]) {
392-
const buildFile = BUILD_FILE_HEADER + printScope(scope, pkgs);
407+
const buildFile = generateBuildFileHeader() + printScope(scope, pkgs);
393408
writeFileSync(path.posix.join(scope, 'BUILD.bazel'), buildFile);
394409
}
395410

@@ -407,6 +422,13 @@ function isDirectory(p: string) {
407422
return fs.existsSync(p) && fs.statSync(p).isDirectory();
408423
}
409424

425+
/**
426+
* Strips the byte order mark from a string if present
427+
*/
428+
function stripBom(s: string) {
429+
return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
430+
}
431+
410432
/**
411433
* Returns an array of all the files under a directory as relative
412434
* paths to the directory.
@@ -468,10 +490,24 @@ function hasRootBuildFile(pkg: Dep, rootPath: string) {
468490
return false;
469491
}
470492

493+
/**
494+
* Returns a set of the root package.json files direct dependencies
495+
*/
496+
export function getDirectDependencySet(pkgJsonPath: string): Set<string> {
497+
const pkgJson = JSON.parse(
498+
stripBom(fs.readFileSync(pkgJsonPath, {encoding: 'utf8'}))
499+
);
500+
501+
const dependencies: string[] = Object.keys(pkgJson.dependencies || {});
502+
const devDependencies: string[] = Object.keys(pkgJson.devDependencies || {});
503+
504+
return new Set([...dependencies, ...devDependencies]);
505+
}
506+
471507
/**
472508
* Finds and returns an array of all packages under a given path.
473509
*/
474-
function findPackages(p = 'node_modules') {
510+
function findPackages(p: string, dependencies: Set<string>) {
475511
if (!isDirectory(p)) {
476512
return [];
477513
}
@@ -490,13 +526,13 @@ function findPackages(p = 'node_modules') {
490526
.filter(f => isDirectory(f));
491527

492528
packages.forEach(f => {
493-
pkgs.push(parsePackage(f), ...findPackages(path.posix.join(f, 'node_modules')));
529+
pkgs.push(parsePackage(f, dependencies), ...findPackages(path.posix.join(f, 'node_modules'), dependencies));
494530
});
495531

496532
const scopes = listing.filter(f => f.startsWith('@'))
497533
.map(f => path.posix.join(p, f))
498534
.filter(f => isDirectory(f));
499-
scopes.forEach(f => pkgs.push(...findPackages(f)));
535+
scopes.forEach(f => pkgs.push(...findPackages(f, dependencies)));
500536

501537
return pkgs;
502538
}
@@ -525,10 +561,9 @@ function findScopes() {
525561
* package json and return it as an object along with
526562
* some additional internal attributes prefixed with '_'.
527563
*/
528-
export function parsePackage(p: string): Dep {
564+
export function parsePackage(p: string, dependencies: Set<string> = new Set()): Dep {
529565
// Parse the package.json file of this package
530566
const packageJson = path.posix.join(p, 'package.json');
531-
const stripBom = (s: string) => s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
532567
const pkg = isFile(packageJson) ?
533568
JSON.parse(stripBom(fs.readFileSync(packageJson, {encoding: 'utf8'}))) :
534569
{version: '0.0.0'};
@@ -559,6 +594,10 @@ export function parsePackage(p: string): Dep {
559594
// which is later filled with the flattened dependency list
560595
pkg._dependencies = [];
561596

597+
// set if this is a direct dependency of the root package.json file
598+
// which is later used to determine the generated rules visibility
599+
pkg._directDependency = dependencies.has(pkg._moduleName);
600+
562601
return pkg;
563602
}
564603

@@ -1131,6 +1170,7 @@ type Dep = {
11311170
_dependencies: Dep[],
11321171
_files: string[],
11331172
_runfiles: string[],
1173+
_directDependency: boolean,
11341174
[k: string]: any
11351175
}
11361176

internal/npm_install/index.js

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* THIS FILE GENERATED FROM .ts; see BUILD.bazel */ /* clang-format off */'use strict';
2+
var _a;
23
Object.defineProperty(exports, "__esModule", { value: true });
34
const fs = require("fs");
45
const path = require("path");
@@ -7,19 +8,24 @@ function log_verbose(...m) {
78
if (!!process.env['VERBOSE_LOGS'])
89
console.error('[generate_build_file.ts]', ...m);
910
}
10-
const BUILD_FILE_HEADER = `# Generated file from yarn_install/npm_install rule.
11+
const args = process.argv.slice(2);
12+
const WORKSPACE = args[0];
13+
const RULE_TYPE = args[1];
14+
const PKG_JSON_FILE_PATH = args[2];
15+
const LOCK_FILE_PATH = args[3];
16+
const STRICT_VISIBILITY = ((_a = args[4]) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'true';
17+
const INCLUDED_FILES = args[5] ? args[5].split(',') : [];
18+
const BAZEL_VERSION = args[6];
19+
const PUBLIC_VISIBILITY = '//visibility:public';
20+
const LIMITED_VISIBILITY = `@${WORKSPACE}//:__subpackages__`;
21+
function generateBuildFileHeader(visibility = PUBLIC_VISIBILITY) {
22+
return `# Generated file from ${RULE_TYPE} rule.
1123
# See rules_nodejs/internal/npm_install/generate_build_file.ts
1224
13-
# All rules in other repositories can use these targets
14-
package(default_visibility = ["//visibility:public"])
25+
package(default_visibility = ["${visibility}"])
1526
1627
`;
17-
const args = process.argv.slice(2);
18-
const WORKSPACE = args[0];
19-
const RULE_TYPE = args[1];
20-
const LOCK_FILE_PATH = args[2];
21-
const INCLUDED_FILES = args[3] ? args[3].split(',') : [];
22-
const BAZEL_VERSION = args[4];
28+
}
2329
if (require.main === module) {
2430
main();
2531
}
@@ -34,7 +40,8 @@ function writeFileSync(p, content) {
3440
fs.writeFileSync(p, content);
3541
}
3642
function main() {
37-
const pkgs = findPackages();
43+
const deps = getDirectDependencySet(PKG_JSON_FILE_PATH);
44+
const pkgs = findPackages('node_modules', deps);
3845
flattenDependencies(pkgs);
3946
generateBazelWorkspaces(pkgs);
4047
generateBuildFiles(pkgs);
@@ -79,7 +86,7 @@ function generateRootBuildFile(pkgs) {
7986
`;
8087
});
8188
});
82-
let buildFile = BUILD_FILE_HEADER + `load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
89+
let buildFile = generateBuildFileHeader() + `load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
8390
8491
exports_files([
8592
${exportsStarlark}])
@@ -114,10 +121,11 @@ function generatePackageBuildFiles(pkg) {
114121
else {
115122
buildFilePath = 'BUILD.bazel';
116123
}
124+
const visibility = !pkg._directDependency && STRICT_VISIBILITY ? LIMITED_VISIBILITY : PUBLIC_VISIBILITY;
117125
if (!pkg._files.includes('bin/BUILD.bazel') && !pkg._files.includes('bin/BUILD')) {
118126
const binBuildFile = printPackageBin(pkg);
119127
if (binBuildFile.length) {
120-
writeFileSync(path.posix.join(pkg._dir, 'bin', 'BUILD.bazel'), BUILD_FILE_HEADER + binBuildFile);
128+
writeFileSync(path.posix.join(pkg._dir, 'bin', 'BUILD.bazel'), generateBuildFileHeader(visibility) + binBuildFile);
121129
}
122130
}
123131
if (pkg._files.includes('index.bzl')) {
@@ -146,7 +154,7 @@ exports_files(["index.bzl"])
146154
`;
147155
}
148156
}
149-
writeFileSync(path.posix.join(pkg._dir, buildFilePath), BUILD_FILE_HEADER + buildFile);
157+
writeFileSync(path.posix.join(pkg._dir, buildFilePath), generateBuildFileHeader(visibility) + buildFile);
150158
}
151159
function generateBazelWorkspaces(pkgs) {
152160
const workspaces = {};
@@ -247,7 +255,7 @@ You can suppress this message by passing "suppress_warning = True" to install_ba
247255
writeFileSync('install_bazel_dependencies.bzl', bzlFile);
248256
}
249257
function generateScopeBuildFiles(scope, pkgs) {
250-
const buildFile = BUILD_FILE_HEADER + printScope(scope, pkgs);
258+
const buildFile = generateBuildFileHeader() + printScope(scope, pkgs);
251259
writeFileSync(path.posix.join(scope, 'BUILD.bazel'), buildFile);
252260
}
253261
function isFile(p) {
@@ -256,6 +264,9 @@ function isFile(p) {
256264
function isDirectory(p) {
257265
return fs.existsSync(p) && fs.statSync(p).isDirectory();
258266
}
267+
function stripBom(s) {
268+
return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
269+
}
259270
function listFiles(rootDir, subDir = '') {
260271
const dir = path.posix.join(rootDir, subDir);
261272
if (!isDirectory(dir)) {
@@ -294,7 +305,14 @@ function hasRootBuildFile(pkg, rootPath) {
294305
}
295306
return false;
296307
}
297-
function findPackages(p = 'node_modules') {
308+
function getDirectDependencySet(pkgJsonPath) {
309+
const pkgJson = JSON.parse(stripBom(fs.readFileSync(pkgJsonPath, { encoding: 'utf8' })));
310+
const dependencies = Object.keys(pkgJson.dependencies || {});
311+
const devDependencies = Object.keys(pkgJson.devDependencies || {});
312+
return new Set([...dependencies, ...devDependencies]);
313+
}
314+
exports.getDirectDependencySet = getDirectDependencySet;
315+
function findPackages(p, dependencies) {
298316
if (!isDirectory(p)) {
299317
return [];
300318
}
@@ -306,12 +324,12 @@ function findPackages(p = 'node_modules') {
306324
.map(f => path.posix.join(p, f))
307325
.filter(f => isDirectory(f));
308326
packages.forEach(f => {
309-
pkgs.push(parsePackage(f), ...findPackages(path.posix.join(f, 'node_modules')));
327+
pkgs.push(parsePackage(f, dependencies), ...findPackages(path.posix.join(f, 'node_modules'), dependencies));
310328
});
311329
const scopes = listing.filter(f => f.startsWith('@'))
312330
.map(f => path.posix.join(p, f))
313331
.filter(f => isDirectory(f));
314-
scopes.forEach(f => pkgs.push(...findPackages(f)));
332+
scopes.forEach(f => pkgs.push(...findPackages(f, dependencies)));
315333
return pkgs;
316334
}
317335
function findScopes() {
@@ -326,9 +344,8 @@ function findScopes() {
326344
.map(f => f.replace(/^node_modules\//, ''));
327345
return scopes;
328346
}
329-
function parsePackage(p) {
347+
function parsePackage(p, dependencies = new Set()) {
330348
const packageJson = path.posix.join(p, 'package.json');
331-
const stripBom = (s) => s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
332349
const pkg = isFile(packageJson) ?
333350
JSON.parse(stripBom(fs.readFileSync(packageJson, { encoding: 'utf8' }))) :
334351
{ version: '0.0.0' };
@@ -339,6 +356,7 @@ function parsePackage(p) {
339356
pkg._files = listFiles(p);
340357
pkg._runfiles = pkg._files.filter((f) => !/[^\x21-\x7E]/.test(f));
341358
pkg._dependencies = [];
359+
pkg._directDependency = dependencies.has(pkg._moduleName);
342360
return pkg;
343361
}
344362
exports.parsePackage = parsePackage;

internal/npm_install/npm_install.bzl

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ fine grained npm dependencies.
8383
default = True,
8484
doc = "If stdout and stderr should be printed to the terminal.",
8585
),
86+
"strict_visibility": attr.bool(
87+
default = False,
88+
doc = """Turn on stricter visibility for generated BUILD.bazel files
89+
90+
When enabled, only dependencies within the given `package.json` file are given public visibility.
91+
All transitive dependencies are given limited visibility, enforcing that all direct dependencies are
92+
listed in the `package.json` file.
93+
94+
Currently the default is set `False`, but will likely be flipped `True` in rules_nodejs 3.0.0
95+
""",
96+
),
8697
"symlink_node_modules": attr.bool(
8798
doc = """Turn symlinking of node_modules on
8899
@@ -115,7 +126,9 @@ def _create_build_files(repository_ctx, rule_type, node, lock_file):
115126
"index.js",
116127
repository_ctx.attr.name,
117128
rule_type,
129+
repository_ctx.path(repository_ctx.attr.package_json),
118130
repository_ctx.path(lock_file),
131+
str(repository_ctx.attr.strict_visibility),
119132
",".join(repository_ctx.attr.included_files),
120133
native.bazel_version,
121134
])

internal/npm_install/test/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jasmine_node_test(
1111
name = "test",
1212
srcs = ["generate_build_file.spec.js"],
1313
data = [
14+
"package.spec.json",
1415
":check.js",
1516
":goldens",
1617
"//internal/npm_install:compile_generate_build_file",

0 commit comments

Comments
 (0)