Skip to content

Commit 26a765f

Browse files
devversionjbedard
authored andcommitted
WIP
1 parent a5f84ae commit 26a765f

File tree

19 files changed

+640
-232
lines changed

19 files changed

+640
-232
lines changed

MODULE.bazel

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ bazel_dep(name = "aspect_tools_telemetry", version = "0.2.8")
1313
bazel_dep(name = "bazel_features", version = "1.9.0")
1414
bazel_dep(name = "bazel_skylib", version = "1.5.0")
1515
bazel_dep(name = "platforms", version = "0.0.5")
16-
bazel_dep(name = "rules_nodejs", version = "6.3.0")
16+
bazel_dep(name = "rules_nodejs", version = "6.4.0")
1717

1818
tel = use_extension("@aspect_tools_telemetry//:extension.bzl", "telemetry")
1919
use_repo(tel, "aspect_tools_telemetry_report")
@@ -67,6 +67,13 @@ node_dev = use_extension(
6767
"node",
6868
dev_dependency = True,
6969
)
70+
use_repo(node_dev, "node22_linux_amd64")
71+
use_repo(node_dev, "node22_darwin_arm64")
72+
use_repo(node_dev, "node22_darwin_amd64")
73+
use_repo(node_dev, "node22_linux_arm64")
74+
use_repo(node_dev, "node22_linux_s390x")
75+
use_repo(node_dev, "node22_linux_ppc64le")
76+
use_repo(node_dev, "node22_windows_amd64")
7077
use_repo(node_dev, "node20_linux_amd64")
7178
use_repo(node_dev, "node20_darwin_arm64")
7279
use_repo(node_dev, "node20_darwin_amd64")
@@ -90,6 +97,10 @@ node_dev.toolchain(
9097
name = "node20",
9198
node_version = "20.17.0",
9299
)
100+
node_dev.toolchain(
101+
name = "node22",
102+
node_version = "22.15.1",
103+
)
93104

94105
############################################
95106
# npm dependencies used by examples

js/private/node-patches/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ write_source_files(
44
name = "checked_in_compile",
55
files = {
66
"fs.cjs": "//js/private/node-patches/src:fs-generated.cjs",
7+
"fs_stat.cjs": "//js/private/node-patches/src:fs_stat.cjs",
78
},
89
)
910

1011
exports_files([
1112
"fs.cjs",
13+
"fs_stat.cjs",
1214
"register.cjs",
1315
])

js/private/node-patches/fs.cjs

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ exports.isSubPath = isSubPath;
4242
exports.escapeFunction = escapeFunction;
4343
const path = require("path");
4444
const util = require("util");
45+
const fs_stat_cjs_1 = require("./fs_stat.cjs");
4546
// using require here on purpose so we can override methods with any
4647
// also even though imports are mutable in typescript the cognitive dissonance is too high because
4748
// es modules
48-
const _fs = require('node:fs');
49+
const fs = require('node:fs');
4950
const url = require('node:url');
5051
const HOP_NON_LINK = Symbol.for('HOP NON LINK');
5152
const HOP_NOT_FOUND = Symbol.for('HOP NOT FOUND');
@@ -65,8 +66,6 @@ function patcher(fs = _fs, roots) {
6566
}
6667
return;
6768
}
68-
const origLstat = fs.lstat.bind(fs);
69-
const origLstatSync = fs.lstatSync.bind(fs);
7069
const origReaddir = fs.readdir.bind(fs);
7170
const origReaddirSync = fs.readdirSync.bind(fs);
7271
const origReadlink = fs.readlink.bind(fs);
@@ -141,22 +140,21 @@ function patcher(fs = _fs, roots) {
141140
return stats;
142141
}
143142
try {
144-
args[0] = unguardedRealPathSync(args[0]);
145-
// there are no hops so lets report the stats of the real file;
146-
// we can't use origRealPathSync here since that function calls lstat internally
147-
// which can result in an infinite loop
148-
return origLstatSync(...args);
143+
lstatPatcher.originalSyncRequested = true;
144+
return fs.lstatSync.apply(fs, args);
149145
}
150-
catch (err) {
151-
if (err.code === 'ENOENT') {
152-
// broken link so there is nothing more to do
153-
return stats;
154-
}
155-
throw err;
146+
finally {
147+
lstatPatcher.originalSyncRequested = false;
156148
}
157149
};
158150
// =========================================================================
159-
// fs.realpath
151+
// fsInternal.lstat (to patch ESM resolve's `realpathSync`!) and
152+
// fs.lstat implementations.
153+
// fs.realpath implementations.
154+
// =========================================================================
155+
lstatPatcher.patch();
156+
// =========================================================================
157+
// fs.realpath.native
160158
// =========================================================================
161159
fs.realpath = function realpath(...args) {
162160
// preserve error when calling function without required callback
@@ -196,14 +194,6 @@ function patcher(fs = _fs, roots) {
196194
};
197195
origRealpathNative(...args);
198196
};
199-
fs.realpathSync = function realpathSync(...args) {
200-
const str = origRealpathSync(...args);
201-
const escapedRoot = isEscape(args[0], str);
202-
if (escapedRoot) {
203-
return guardedRealPathSync(args[0], escapedRoot);
204-
}
205-
return str;
206-
};
207197
fs.realpathSync.native = function native_realpathSync(...args) {
208198
const str = origRealpathSyncNative(...args);
209199
const escapedRoot = isEscape(args[0], str);
@@ -368,7 +358,6 @@ function patcher(fs = _fs, roots) {
368358
const promisePropertyDescriptor = Object.getOwnPropertyDescriptor(fs, 'promises');
369359
if (promisePropertyDescriptor) {
370360
const promises = {};
371-
promises.lstat = util.promisify(fs.lstat);
372361
// NOTE: node core uses the newer realpath function fs.promises.native instead of fs.realPath
373362
promises.realpath = util.promisify(fs.realpath.native);
374363
promises.readlink = util.promisify(fs.readlink);
@@ -740,6 +729,7 @@ function stringifyPathLike(p) {
740729
function resolvePathLike(p) {
741730
return path.resolve(stringifyPathLike(p));
742731
}
732+
exports.resolvePathLike = resolvePathLike;
743733
function normalizePathLike(p) {
744734
const s = stringifyPathLike(p);
745735
// TODO: are URLs always absolute?
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use strict";
2+
// Patches Node's internal FS bindings, right before they would call into C++.
3+
// See full context in: https://github.com/aspect-build/rules_js/issues/362.
4+
// This is to ensure ESM imports don't escape accidentally via `realpathSync`.
5+
Object.defineProperty(exports, "__esModule", { value: true });
6+
exports.FsInternalStatPatcher = void 0;
7+
/// <reference path="./fs_stat_types.d.cts" />
8+
const binding_1 = require("internal/test/binding");
9+
const utils_1 = require("internal/fs/utils");
10+
const fs_cjs_1 = require("./fs.cjs");
11+
const internalFs = (0, binding_1.internalBinding)('fs');
12+
class FsInternalStatPatcher {
13+
constructor(escapeFns, guardedReadLink, guardedReadLinkSync, unguardedRealPath, unguardedRealPathSync) {
14+
this.escapeFns = escapeFns;
15+
this.guardedReadLink = guardedReadLink;
16+
this.guardedReadLinkSync = guardedReadLinkSync;
17+
this.unguardedRealPath = unguardedRealPath;
18+
this.unguardedRealPathSync = unguardedRealPathSync;
19+
this._originalFsLstat = internalFs.lstat;
20+
this.originalSyncRequested = false;
21+
}
22+
revert() {
23+
internalFs.lstat = this._originalFsLstat;
24+
}
25+
patch() {
26+
const statPatcher = this;
27+
internalFs.lstat = function (path, bigint, reqCallback, throwIfNoEntry) {
28+
if (this.originalSyncRequested) {
29+
return statPatcher._originalFsLstat.call(internalFs, path, bigint, reqCallback, throwIfNoEntry);
30+
}
31+
if (reqCallback === internalFs.kUsePromises) {
32+
return statPatcher._originalFsLstat.call(internalFs, path, bigint, reqCallback, throwIfNoEntry).then((stats) => {
33+
return new Promise((resolve, reject) => {
34+
statPatcher.eeguardStats(path, bigint, stats, throwIfNoEntry, (err, guardedStats) => {
35+
err || !guardedStats ? reject(err) : resolve(guardedStats);
36+
});
37+
});
38+
});
39+
}
40+
else if (reqCallback === undefined) {
41+
const stats = statPatcher._originalFsLstat.call(internalFs, path, bigint, undefined, throwIfNoEntry);
42+
if (!stats) {
43+
return stats;
44+
}
45+
return statPatcher.eeguardStatsSync(path, bigint, throwIfNoEntry, stats);
46+
}
47+
else {
48+
// Just re-use the promise path from above.
49+
internalFs.lstat(path, bigint, internalFs.kUsePromises, throwIfNoEntry)
50+
.then((stats) => reqCallback.oncomplete(null, stats))
51+
.catch((err) => reqCallback.oncomplete(err));
52+
}
53+
};
54+
}
55+
eeguardStats(path, bigint, stats, throwIfNotFound, cb) {
56+
const statsObj = (0, utils_1.getStatsFromBinding)(stats);
57+
if (!statsObj.isSymbolicLink()) {
58+
// the file is not a symbolic link so there is nothing more to do
59+
return cb(null, stats);
60+
}
61+
path = (0, fs_cjs_1.resolvePathLike)(path);
62+
if (!this.escapeFns.canEscape(path)) {
63+
// the file can not escaped the sandbox so there is nothing more to do
64+
return cb(null, stats);
65+
}
66+
return this.guardedReadLink(path, (str) => {
67+
if (str != path) {
68+
// there are one or more hops within the guards so there is nothing more to do
69+
return cb(null, stats);
70+
}
71+
// there are no hops so lets report the stats of the real file;
72+
// we can't use origRealPath here since that function calls lstat internally
73+
// which can result in an infinite loop
74+
return this.unguardedRealPath(path, (err, str) => {
75+
if (err) {
76+
if (err.code === 'ENOENT') {
77+
// broken link so there is nothing more to do
78+
return cb(null, stats);
79+
}
80+
return cb(err);
81+
}
82+
// Forward request to original callback.
83+
const req2 = new internalFs.FSReqCallback(bigint);
84+
req2.oncomplete = (err, realStats) => cb(err, realStats);
85+
return this._originalFsLstat.call(internalFs, str, bigint, req2, throwIfNotFound);
86+
});
87+
});
88+
}
89+
eeguardStatsSync(path, bigint, throwIfNoEntry, stats) {
90+
// No stats available.
91+
if (!stats) {
92+
return stats;
93+
}
94+
const statsObj = (0, utils_1.getStatsFromBinding)(stats);
95+
if (!statsObj.isSymbolicLink()) {
96+
// the file is not a symbolic link so there is nothing more to do
97+
return stats;
98+
}
99+
path = (0, fs_cjs_1.resolvePathLike)(path);
100+
if (!this.escapeFns.canEscape(path)) {
101+
// the file can not escaped the sandbox so there is nothing more to do
102+
return stats;
103+
}
104+
const guardedReadLink = this.guardedReadLinkSync(path);
105+
if (guardedReadLink != path) {
106+
// there are one or more hops within the guards so there is nothing more to do
107+
return stats;
108+
}
109+
try {
110+
path = this.unguardedRealPathSync(path);
111+
// there are no hops so lets report the stats of the real file;
112+
// we can't use origRealPathSync here since that function calls lstat internally
113+
// which can result in an infinite loop
114+
return this._originalFsLstat.call(internalFs, path, bigint, undefined, throwIfNoEntry);
115+
}
116+
catch (err) {
117+
if (err.code === 'ENOENT') {
118+
// broken link so there is nothing more to do
119+
return stats;
120+
}
121+
throw err;
122+
}
123+
}
124+
}
125+
exports.FsInternalStatPatcher = FsInternalStatPatcher;

js/private/node-patches/register.cjs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,14 @@ if (
3535
JS_BINARY__PATCH_NODE_FS != '0' &&
3636
JS_BINARY__FS_PATCH_ROOTS
3737
) {
38-
const fs = require('node:fs')
3938
const module = require('node:module')
4039
const roots = JS_BINARY__FS_PATCH_ROOTS.split(':')
4140
if (JS_BINARY__LOG_DEBUG) {
4241
console.error(
4342
`DEBUG: ${JS_BINARY__LOG_PREFIX}: node fs patches will be applied with roots: ${roots}`
4443
)
4544
}
46-
patchfs(fs, roots)
45+
patchfs(roots)
4746

4847
// Sync the esm modules to use the now patched fs cjs module.
4948
// See: https://nodejs.org/api/esm.html#builtin-modules

js/private/node-patches/src/BUILD.bazel

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@ typescript_bin.tsc(
44
name = "compile",
55
srcs = [
66
"fs.cts",
7+
"fs_stat.cts",
8+
"fs_stat_types.d.cts",
79
"tsconfig.json",
810
"//:node_modules/@types/node",
911
],
1012
outs = [
1113
"fs.cjs",
14+
"fs_stat.cjs",
1215
],
1316
args = [
1417
"-p",
1518
"tsconfig.json",
1619
],
1720
chdir = package_name(),
18-
visibility = ["//js/private/test/node-patches:__pkg__"],
21+
visibility = [
22+
"//js/private/node-patches:__pkg__",
23+
"//js/private/test/node-patches:__pkg__",
24+
],
1925
)
2026

2127
genrule(

js/private/node-patches/src/fs.cts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type * as FsType from 'fs'
2020
import type * as UrlType from 'url'
2121
import * as path from 'path'
2222
import * as util from 'util'
23+
import { FsInternalStatPatcher } from './fs_stat.cjs'
2324

2425
// windows cant find the right types
2526
type Dir = any
@@ -28,16 +29,23 @@ type Dirent = any
2829
// using require here on purpose so we can override methods with any
2930
// also even though imports are mutable in typescript the cognitive dissonance is too high because
3031
// es modules
31-
const _fs = require('node:fs') as typeof FsType
32+
const fs = require('node:fs') as any
3233
const url = require('node:url') as typeof UrlType
3334

3435
const HOP_NON_LINK = Symbol.for('HOP NON LINK')
3536
const HOP_NOT_FOUND = Symbol.for('HOP NOT FOUND')
3637

3738
type HopResults = string | typeof HOP_NON_LINK | typeof HOP_NOT_FOUND
3839

39-
export function patcher(fs: any = _fs, roots: string[]) {
40-
fs = fs || _fs
40+
/**
41+
* Function that patches the `fs` module to not escape the given roots.
42+
* @returns a function to undo the patches.
43+
*/
44+
export function patcher(roots: string[]): void {
45+
if (fs._unpatched) {
46+
throw new Error('FS is already patched.')
47+
}
48+
4149
// Make the original version of the library available for when access to the
4250
// unguarded file system is necessary, such as the esbuild plugin that
4351
// protects against sandbox escaping that occurs through module resolution
@@ -77,6 +85,19 @@ export function patcher(fs: any = _fs, roots: string[]) {
7785

7886
const { canEscape, isEscape } = escapeFunction(roots)
7987

88+
// =========================================================================
89+
// fsInternal.lstat (to patch ESM resolve's `realpathSync`!)
90+
// =========================================================================
91+
const lstatEsmPatcher = new FsInternalStatPatcher(
92+
{ canEscape, isEscape },
93+
guardedReadLink,
94+
guardedReadLinkSync,
95+
unguardedRealPath,
96+
unguardedRealPathSync
97+
)
98+
99+
lstatEsmPatcher.patch()
100+
80101
// =========================================================================
81102
// fs.lstat
82103
// =========================================================================
@@ -436,7 +457,6 @@ export function patcher(fs: any = _fs, roots: string[]) {
436457
)
437458
if (promisePropertyDescriptor) {
438459
const promises: any = {}
439-
promises.lstat = util.promisify(fs.lstat)
440460
// NOTE: node core uses the newer realpath function fs.promises.native instead of fs.realPath
441461
promises.realpath = util.promisify(fs.realpath.native)
442462
promises.readlink = util.promisify(fs.readlink)
@@ -819,6 +839,24 @@ export function patcher(fs: any = _fs, roots: string[]) {
819839
}
820840
}
821841
}
842+
843+
return () => {
844+
fs.realpath.native = origRealpathNative
845+
fs.readlink = origReadlink
846+
fs.readlinkSync = origReadlinkSync
847+
fs.readdir = origReaddir
848+
fs.readdirSync = origReaddirSync
849+
fs.opendir = origReaddir
850+
851+
fs.promises.realpath = util.promisify(fs.realpath.native)
852+
fs.promises.readlink = util.promisify(fs.readlink)
853+
fs.promises.readdir = util.promisify(fs.readdir)
854+
fs.promises.opendir = util.promisify(fs.opendir)
855+
856+
lstatEsmPatcher.revert()
857+
858+
fs._unpatched = undefined
859+
}
822860
}
823861

824862
// =========================================================================
@@ -840,7 +878,7 @@ function stringifyPathLike(p: PathLike): string {
840878
}
841879
}
842880

843-
function resolvePathLike(p: PathLike): string {
881+
export function resolvePathLike(p: PathLike): string {
844882
return path.resolve(stringifyPathLike(p))
845883
}
846884

0 commit comments

Comments
 (0)