Skip to content

Commit 0ba36c5

Browse files
committed
lib: have runFile honor permissions and accept URL/Buffer paths
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode
1 parent 1ead042 commit 0ba36c5

3 files changed

Lines changed: 120 additions & 19 deletions

File tree

doc/api/bench.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ for await (const { type, data } of run()) {
504504
added: REPLACEME
505505
-->
506506

507-
* `path` {string} The absolute path of one benchmark module.
507+
* `path` {string|Buffer|URL} The path of one benchmark module.
508508
* `options` {Object}
509509
* `env` {Object} The child process environment. Property values must be
510510
strings or `undefined`. This replaces, rather than extends, the parent
@@ -518,18 +518,22 @@ added: REPLACEME
518518
* Returns: {BenchmarksStream}
519519

520520
Runs exactly one benchmark module in a fresh child process and returns its
521-
object-mode event stream. `path` is not interpreted as a glob. Unless the signal
522-
is aborted or the stream is destroyed before startup, every call uses a new
523-
child. Input discovery, ordering, concurrency, retries, and multi-file
524-
scheduling remain the caller's responsibility.
521+
object-mode event stream. A relative `path` is resolved from the current working
522+
directory when `runFile()` is called. `path` is not interpreted as a glob.
523+
Unless the signal is aborted or the stream is destroyed before startup, every
524+
call uses a new child. Input discovery, ordering, concurrency, retries, and
525+
multi-file scheduling remain the caller's responsibility.
526+
527+
When the Permission Model is enabled, the caller must have file system read
528+
access to `path` and permission to create child processes.
525529

526530
Records use advanced child process serialization, preserving supported
527531
structured values such as `bigint` and errors. Child writes to stdout and stderr
528-
become `'bench:diagnostic'` records. A module loading error, abnormal child exit,
529-
or cancellation also emits an error diagnostic and produces a terminal
530-
`'bench:summary'` whose `success` property is `false`; these execution failures
531-
do not error the stream. If module evaluation fails after declaring benchmarks,
532-
those declarations still run before the unsuccessful summary.
532+
become `'bench:diagnostic'` records. A permission failure, module loading error,
533+
abnormal child exit, or cancellation also emits an error diagnostic and produces
534+
a terminal `'bench:summary'` whose `success` property is `false`; these execution
535+
failures do not error the stream. If module evaluation fails after declaring
536+
benchmarks, those declarations still run before the unsuccessful summary.
533537

534538
`env`, effective inherited options, and an explicitly provided `execArgv` are
535539
copied when `runFile()` is called. The runner removes `NODE_OPTIONS`, replaces

lib/internal/bench_runner/cli.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,14 @@ const {
3636
StringPrototypeStartsWith,
3737
StringPrototypeToUpperCase,
3838
SymbolDispose,
39+
uncurryThis,
3940
} = primordials;
41+
const { Buffer } = require('buffer');
42+
const BufferToString = uncurryThis(Buffer.prototype.toString);
4043
const { spawn } = require('child_process');
4144
const { createWriteStream, statSync } = require('fs');
4245
const { Glob } = require('internal/fs/glob');
46+
const { getValidatedPath } = require('internal/fs/utils');
4347
const {
4448
BenchmarksStream,
4549
} = require('internal/bench_runner/benchmarks_stream');
@@ -53,6 +57,7 @@ const { deserializeError, serializeError } = require('internal/error_serdes');
5357
const {
5458
AbortError,
5559
codes: {
60+
ERR_ACCESS_DENIED,
5661
ERR_INVALID_ARG_TYPE,
5762
ERR_INVALID_ARG_VALUE,
5863
ERR_INVALID_STATE,
@@ -64,6 +69,7 @@ const {
6469
getOptionValue,
6570
getOptionsAsFlagsFromBinding,
6671
} = require('internal/options');
72+
const permission = require('internal/process/permission');
6773
const { TIMEOUT_MAX } = require('internal/timers');
6874
const { kEmptyObject } = require('internal/util');
6975
const {
@@ -75,7 +81,7 @@ const {
7581
} = require('internal/validators');
7682
const { pathToFileURL } = require('internal/url');
7783
const { pipeline } = require('stream/promises');
78-
const { isAbsolute, resolve, sep } = require('path');
84+
const { resolve, sep } = require('path');
7985
const { clearTimeout, setTimeout } = require('timers');
8086

8187
const console = require('internal/console/global');
@@ -758,6 +764,16 @@ async function runChild(path, options, scope, onRecord) {
758764
}),
759765
};
760766
}
767+
const resource = resolve(options.cwd, path);
768+
if (permission.isEnabled() &&
769+
!permission.has('fs.read', resource) &&
770+
!permission.isAuditMode()) {
771+
throw new ERR_ACCESS_DENIED(
772+
'Access to this API has been restricted. Use --allow-fs-read to manage permissions.',
773+
'FileSystemRead',
774+
resource,
775+
);
776+
}
761777
const child = spawn(
762778
options.execPath ?? process.execPath,
763779
getChildArgs(path, options),
@@ -1116,10 +1132,8 @@ async function runIsolated(files, options, output) {
11161132
}
11171133

11181134
function runFile(path, options = kEmptyObject) {
1119-
validateStringWithoutNullBytes(path, 'path');
1120-
if (!isAbsolute(path)) {
1121-
throw new ERR_INVALID_ARG_VALUE('path', path, 'must be an absolute path');
1122-
}
1135+
path = getValidatedPath(path);
1136+
if (typeof path !== 'string') path = BufferToString(path);
11231137
const file = resolve(path);
11241138
validateObject(options, 'options');
11251139
const {

test/parallel/test-bench-run-file.js

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ const path = require('path');
1010
const { runFile } = require('node:bench');
1111

1212
const fixture = fixtures.path('bench-runner/run-file.cjs');
13+
const relativeFixture = path.relative(process.cwd(), fixture);
1314

1415
assert.throws(() => runFile(null), { code: 'ERR_INVALID_ARG_TYPE' });
15-
assert.throws(() => runFile('relative.cjs'), { code: 'ERR_INVALID_ARG_VALUE' });
1616
assert.throws(() => runFile(fixture, null), { code: 'ERR_INVALID_ARG_TYPE' });
1717
assert.throws(() => runFile(fixture, { execArgv: null }), {
1818
code: 'ERR_INVALID_ARG_TYPE',
@@ -50,6 +50,9 @@ assert.throws(() => runFile(fixture, {
5050
assert.throws(() => runFile(`${fixture}\0`), {
5151
code: 'ERR_INVALID_ARG_VALUE',
5252
});
53+
assert.throws(() => runFile(Buffer.from(`${fixture}\0`)), {
54+
code: 'ERR_INVALID_ARG_VALUE',
55+
});
5356
assert.throws(() => runFile(fixture, { env: null }), {
5457
code: 'ERR_INVALID_ARG_TYPE',
5558
});
@@ -79,7 +82,7 @@ async function testRunFile() {
7982
NODE_CHANNEL_FD: '999',
8083
NODE_CHANNEL_SERIALIZATION_MODE: 'json',
8184
};
82-
const stream = runFile(fixture, { env, execArgv });
85+
const stream = runFile(relativeFixture, { env, execArgv });
8386
execArgv.length = 0;
8487
env.NODE_BENCH_RUN_FILE = 'mutated';
8588
const records = await stream.toArray();
@@ -108,8 +111,11 @@ async function testRunFile() {
108111

109112
async function testConcurrentCalls() {
110113
const [cjsRecords, esmRecords] = await Promise.all([
111-
runFile(fixtures.path('bench-runner/a.cjs')).toArray(),
112-
runFile(fixtures.path('bench-runner/b.mjs')).toArray(),
114+
runFile(fixtures.fileURL('bench-runner/a.cjs')).toArray(),
115+
runFile(Buffer.from(path.relative(
116+
process.cwd(),
117+
fixtures.path('bench-runner/b.mjs'),
118+
))).toArray(),
113119
]);
114120
const cjsResult = cjsRecords.find(
115121
({ type }) => type === 'bench:complete').data;
@@ -119,6 +125,14 @@ async function testConcurrentCalls() {
119125
assert.strictEqual(esmResult.name, 'beta');
120126
assert.notStrictEqual(cjsResult.params.pid, esmResult.params.pid);
121127
assert.notStrictEqual(cjsResult.runId, esmResult.runId);
128+
assert.strictEqual(
129+
cjsRecords.at(-1).data.file,
130+
fixtures.path('bench-runner/a.cjs'),
131+
);
132+
assert.strictEqual(
133+
esmRecords.at(-1).data.file,
134+
fixtures.path('bench-runner/b.mjs'),
135+
);
122136
}
123137

124138
async function testLoadFailure() {
@@ -280,6 +294,74 @@ function testEvalParent() {
280294
assert.strictEqual(result.stdout, 'true\n');
281295
}
282296

297+
function testPermissions() {
298+
const fsReadScript = `
299+
const assert = require('assert');
300+
const { runFile } = require('node:bench');
301+
const { pathToFileURL } = require('url');
302+
const target = ${JSON.stringify(fixture)};
303+
assert.strictEqual(process.permission.has('child'), true);
304+
assert.strictEqual(process.permission.has('fs.read', target), false);
305+
Promise.all([
306+
target,
307+
Buffer.from(target),
308+
pathToFileURL(target),
309+
].map(async (input) => {
310+
const records = await runFile(input).toArray();
311+
const diagnostic = records.find(({ type, data }) =>
312+
type === 'bench:diagnostic' &&
313+
data.error?.code === 'ERR_ACCESS_DENIED');
314+
assert.strictEqual(diagnostic.data.error.permission, 'FileSystemRead');
315+
assert.strictEqual(diagnostic.data.error.resource, target);
316+
assert.strictEqual(records.at(-1).type, 'bench:summary');
317+
assert.strictEqual(records.at(-1).data.success, false);
318+
})).catch((error) => {
319+
console.error(error);
320+
process.exitCode = 1;
321+
});
322+
`;
323+
const result = spawnSync(process.execPath, [
324+
'--no-warnings',
325+
'--permission',
326+
'--allow-child-process',
327+
'-e',
328+
fsReadScript,
329+
], { encoding: 'utf8' });
330+
assert.strictEqual(result.status, 0, result.stderr);
331+
332+
const childProcessScript = `
333+
const assert = require('assert');
334+
const { runFile } = require('node:bench');
335+
const target = ${JSON.stringify(fixture)};
336+
assert.strictEqual(process.permission.has('fs.read', target), true);
337+
assert.strictEqual(process.permission.has('child'), false);
338+
runFile(target).toArray().then((records) => {
339+
const diagnostic = records.find(({ type, data }) =>
340+
type === 'bench:diagnostic' &&
341+
data.error?.code === 'ERR_ACCESS_DENIED');
342+
assert.strictEqual(diagnostic.data.error.permission, 'ChildProcess');
343+
assert.strictEqual(diagnostic.data.error.resource, process.execPath);
344+
assert.strictEqual(records.at(-1).type, 'bench:summary');
345+
assert.strictEqual(records.at(-1).data.success, false);
346+
}).catch((error) => {
347+
console.error(error);
348+
process.exitCode = 1;
349+
});
350+
`;
351+
const childProcessResult = spawnSync(process.execPath, [
352+
'--no-warnings',
353+
'--permission',
354+
`--allow-fs-read=${fixture}`,
355+
'-e',
356+
childProcessScript,
357+
], { encoding: 'utf8' });
358+
assert.strictEqual(
359+
childProcessResult.status,
360+
0,
361+
childProcessResult.stderr,
362+
);
363+
}
364+
283365
async function testPreAborted() {
284366
const records = await runFile(fixture, {
285367
signal: AbortSignal.abort(new Error('already cancelled')),
@@ -315,4 +397,5 @@ async function testDestroy() {
315397
await testPreAborted();
316398
await testDestroy();
317399
testEvalParent();
400+
testPermissions();
318401
})().then(common.mustCall());

0 commit comments

Comments
 (0)