| /** | |
| * @license | |
| * Copyright 2019 The Bazel Authors. All rights reserved. | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * | |
| * You may obtain a copy of the License at | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import type { | |
| Dir, | |
| Dirent, | |
| PathLike, | |
| Stats, | |
| StatSyncOptions, | |
| BigIntStats, | |
| } from 'node:fs' | |
| import type * as FsType from 'node:fs' | |
| import * as esmModule from 'node:module' | |
| import * as path from 'node:path' | |
| import * as url from 'node:url' | |
| import * as util from 'node:util' | |
| // using require here on purpose so we can override methods with any | |
| // also even though imports are mutable in typescript the cognitive dissonance is too high because | |
| // es modules | |
| const fs = require('node:fs') as any | |
| const HOP_NON_LINK = Symbol.for('HOP NON LINK') | |
| const HOP_NOT_FOUND = Symbol.for('HOP NOT FOUND') | |
| type HopResults = string | typeof HOP_NON_LINK | typeof HOP_NOT_FOUND | |
| type ErrPathCallback = (err: Error | null, path: string | undefined) => void | |
| const PATCHED_FS_METHODS: ReadonlyArray<keyof typeof FsType> = [ | |
| 'lstat', | |
| 'lstatSync', | |
| 'realpath', | |
| 'realpathSync', | |
| 'readlink', | |
| 'readlinkSync', | |
| 'readdir', | |
| 'readdirSync', | |
| 'opendir', | |
| ] | |
| /** | |
| * Function that patches the `fs` module to not escape the given roots. | |
| * @returns a function to undo the patches. | |
| */ | |
| export function patcher(roots: string[]): () => void { | |
| if (fs._unpatched) { | |
| throw new Error('FS is already patched.') | |
| } | |
| // Make the original version of the library available for when access to the | |
| // unguarded file system is necessary, such as the esbuild plugin that | |
| // protects against sandbox escaping that occurs through module resolution | |
| // in the Go binary. See | |
| // https://github.com/aspect-build/rules_esbuild/issues/58. | |
| fs._unpatched = PATCHED_FS_METHODS.reduce( | |
| (obj, method) => { | |
| obj[method] = fs[method] | |
| return obj | |
| }, | |
| {} as { [key in (typeof PATCHED_FS_METHODS)[number]]: any } | |
| ) | |
| roots = roots || [] | |
| roots = roots.filter((root) => fs.existsSync(root)) | |
| if (!roots.length) { | |
| if (process.env.VERBOSE_LOGS) { | |
| console.error( | |
| 'fs patcher called without any valid root paths ' + __filename | |
| ) | |
| } | |
| return function () {} | |
| } | |
| const origStat = fs.stat.bind(fs) as typeof FsType.stat | |
| const origStatSync = fs.statSync.bind(fs) as typeof FsType.statSync | |
| const origLstat = fs.lstat.bind(fs) as typeof FsType.lstat | |
| const origLstatSync = fs.lstatSync.bind(fs) as typeof FsType.lstatSync | |
| const origReaddir = fs.readdir.bind(fs) as typeof FsType.readdir | |
| const origReaddirSync = fs.readdirSync.bind(fs) as typeof FsType.readdirSync | |
| const origReadlink = fs.readlink.bind(fs) as typeof FsType.readlink | |
| const origReadlinkSync = fs.readlinkSync.bind( | |
| fs | |
| ) as typeof FsType.readlinkSync | |
| const origRealpath = fs.realpath.bind(fs) as typeof FsType.realpath | |
| const origRealpathNative = fs.realpath | |
| .native as typeof FsType.realpath.native | |
| const origRealpathSync = fs.realpathSync.bind( | |
| fs | |
| ) as typeof FsType.realpathSync | |
| const origRealpathSyncNative = fs.realpathSync | |
| .native as typeof FsType.realpathSync.native | |
| const { canEscape, isEscape } = escapeFunction(roots) | |
| const rootMappings = roots | |
| .map((root) => { | |
| const lexical = path.resolve(root) | |
| let real = lexical | |
| // A configured root need not exist on disk when the patch is | |
| // registered (or may be inaccessible); when it can't be realpath'd | |
| // we keep real == lexical, i.e. treat the root as not-symlinked so | |
| // the remapping below is a no-op. | |
| try { | |
| real = origRealpathSyncNative(lexical) as string | |
| } catch {} | |
| return { lexical, real } | |
| }) | |
| .sort((a, b) => b.lexical.length - a.lexical.length) | |
| // Resolve a relative symlink `linkTarget` against the REAL location of the | |
| // link's parent directory. `resolved` is an absolute path whose final | |
| // component is a symlink we just read successfully, but the path we were | |
| // queried through may be an alias of a different depth than the real dir | |
| // (e.g. a pnpm/node_modules alias); joining `linkTarget` onto the *lexical* | |
| // parent would then land at the wrong absolute path. | |
| function resolveTargetAgainstRealParent( | |
| resolved: string, | |
| linkTarget: string | |
| ): string { | |
| if (path.isAbsolute(linkTarget)) return path.resolve(linkTarget) | |
| const linkDir = origRealpathSyncNative(path.dirname(resolved)) as string | |
| const target = path.resolve(linkDir, linkTarget) | |
| // Project only targets that remain inside a symlinked root. Resolving | |
| // relative escapes after projecting the parent would change their depth. | |
| for (const root of rootMappings) { | |
| if ( | |
| isSubPath(root.lexical, resolved) && | |
| isSubPath(root.real, target) | |
| ) { | |
| return path.resolve( | |
| root.lexical, | |
| path.relative(root.real, target) | |
| ) | |
| } | |
| } | |
| return target | |
| } | |
| // A relative path with the same shape is only a valid sandbox remapping | |
| // when it leads to the original target. Otherwise a walker could follow an | |
| // absent or unrelated file in the sandbox. | |
| function hasSameTarget(original: string, mapped: string): boolean { | |
| try { | |
| return ( | |
| origRealpathSyncNative(original) === | |
| origRealpathSyncNative(mapped) | |
| ) | |
| } catch { | |
| return false | |
| } | |
| } | |
| // ========================================================================= | |
| // fs.lstat | |
| // ========================================================================= | |
| fs.lstat = function lstat(...args: Parameters<typeof FsType.lstat>) { | |
| // preserve error when calling function without required callback | |
| if (typeof args[args.length - 1] !== 'function') { | |
| return origLstat(...args) | |
| } | |
| const cb = once(args[args.length - 1] as Function) | |
| // override the callback | |
| args[args.length - 1] = function lstatCb( | |
| err: Error | null, | |
| stats: Stats | BigIntStats | |
| ) { | |
| if (err) return cb(err) | |
| if (!stats.isSymbolicLink()) { | |
| // the file is not a symbolic link so there is nothing more to do | |
| return cb(null, stats) | |
| } | |
| args[0] = resolvePathLike(args[0]) | |
| if (!canEscape(args[0])) { | |
| // the file can not escaped the sandbox so there is nothing more to do | |
| return cb(null, stats) | |
| } | |
| return readlink(args[0], guardedReadLinkCb) | |
| function guardedReadLinkCb(err: NodeJS.ErrnoException | null) { | |
| if (!err) { | |
| // The final component is a visible link in the guarded filesystem. | |
| return cb(null, stats) | |
| } | |
| if (err.code === 'ENOENT') return cb(null, stats) | |
| if (err.code !== 'EINVAL') return cb(err) | |
| // readlink hides this link; return its target's stats, preserving options. | |
| args[args.length - 1] = function statCb(err, targetStats) { | |
| if (err?.code === 'ENOENT') return cb(null, stats) | |
| if (err) return cb(err) | |
| return cb(null, targetStats ?? stats) | |
| } | |
| return origStat(...args) | |
| } | |
| } | |
| origLstat(...args) | |
| } | |
| fs.lstatSync = function lstatSync( | |
| ...args: Parameters<typeof FsType.lstatSync> | |
| ) { | |
| const stats = origLstatSync(...args) | |
| if (!stats?.isSymbolicLink()) { | |
| // the file is not a symbolic link so there is nothing more to do | |
| return stats | |
| } | |
| args[0] = resolvePathLike(args[0]) | |
| if (!canEscape(args[0])) { | |
| // the file can not escaped the sandbox so there is nothing more to do | |
| return stats | |
| } | |
| try { | |
| readlinkSync(args[0]) | |
| return stats | |
| } catch (err: any) { | |
| if (err.code === 'ENOENT') return stats | |
| if (err.code !== 'EINVAL') throw err | |
| } | |
| try { | |
| return origStatSync(...args) ?? stats | |
| } catch (err: any) { | |
| if (err.code === 'ENOENT') { | |
| // broken link so there is nothing more to do | |
| return stats | |
| } | |
| throw err | |
| } | |
| } | |
| // ========================================================================= | |
| // fs.realpath | |
| // ========================================================================= | |
| fs.realpath = function realpath(...args: Parameters<typeof origRealpath>) { | |
| // preserve error when calling function without required callback | |
| if (typeof args[args.length - 1] !== 'function') { | |
| return origRealpath(...args) | |
| } | |
| const cb = once(args[args.length - 1] as Function) | |
| args[args.length - 1] = function realpathCb( | |
| err: Error | null, | |
| str?: string | |
| ) { | |
| if (err) return cb(err) | |
| const escapedRoot: string | false = isEscape(args[0], str!) | |
| if (escapedRoot) { | |
| return guardedRealPath(args[0], cb, [escapedRoot]) | |
| } else { | |
| return cb(null, str) | |
| } | |
| } | |
| origRealpath(...args) | |
| } | |
| fs.realpath.native = function realpath_native( | |
| ...args: Parameters<typeof origRealpathNative> | |
| ) { | |
| // preserve error when calling function without required callback | |
| if (typeof args[args.length - 1] !== 'function') { | |
| return origRealpathNative(...args) | |
| } | |
| const cb = once(args[args.length - 1] as Function) | |
| args[args.length - 1] = function nativeCb( | |
| err: Error | null, | |
| str?: string | |
| ) { | |
| if (err) return cb(err) | |
| const escapedRoot: string | false = isEscape(args[0], str!) | |
| if (escapedRoot) { | |
| return guardedRealPath(args[0], cb, [escapedRoot]) | |
| } else { | |
| return cb(null, str) | |
| } | |
| } | |
| origRealpathNative(...args) | |
| } | |
| fs.realpathSync = function realpathSync( | |
| ...args: Parameters<typeof origRealpathSync> | |
| ) { | |
| const str = origRealpathSync(...args) | |
| const escapedRoot: string | false = isEscape(args[0], str) | |
| if (escapedRoot) { | |
| return guardedRealPathSync(args[0], [escapedRoot]) | |
| } | |
| return str | |
| } | |
| fs.realpathSync.native = function native_realpathSync( | |
| ...args: Parameters<typeof origRealpathSyncNative> | |
| ) { | |
| const str = origRealpathSyncNative(...args) | |
| const escapedRoot: string | false = isEscape(args[0], str) | |
| if (escapedRoot) { | |
| return guardedRealPathSync(args[0], [escapedRoot]) | |
| } | |
| return str | |
| } | |
| // ========================================================================= | |
| // fs.readlink | |
| // ========================================================================= | |
| fs.readlink = readlink | |
| function readlink(...args: Parameters<typeof origReadlink>) { | |
| // preserve error when calling function without required callback | |
| if (typeof args[args.length - 1] !== 'function') { | |
| return origReadlink(...args) | |
| } | |
| const cb = once(args[args.length - 1] as Function) | |
| args[args.length - 1] = function readlinkCb( | |
| err: Error | null, | |
| p?: string | |
| ) { | |
| if (err) return cb(err) | |
| const resolved = resolvePathLike(args[0]) | |
| const linkTarget = p! | |
| let targetAbs: string | |
| try { | |
| targetAbs = resolveTargetAgainstRealParent(resolved, linkTarget) | |
| } catch (error) { | |
| return cb(error) | |
| } | |
| const escapedRoot: string | false = isEscape(resolved, targetAbs) | |
| if (escapedRoot) { | |
| const escapedRoots = [escapedRoot] | |
| return nextHop(targetAbs, readlinkNextHopCb) | |
| function readlinkNextHopCb(next: string | undefined | false) { | |
| if (!next) { | |
| if (next == undefined) { | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| return cb(enoent('readlink', args[0])) | |
| } else { | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| return cb(einval('readlink', args[0])) | |
| } | |
| } | |
| const r = path.resolve( | |
| path.dirname(resolved), | |
| path.relative(path.dirname(targetAbs), next) | |
| ) | |
| if ( | |
| r != resolved && | |
| !isEscape(resolved, r, escapedRoots) && | |
| hasSameTarget(resolved, r) | |
| ) { | |
| if (path.isAbsolute(linkTarget)) { | |
| return cb(null, r) | |
| } | |
| const rel = path.relative(path.dirname(resolved), r) | |
| return cb(null, rel || '.') | |
| } | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| return cb(einval('readlink', args[0])) | |
| } | |
| } else { | |
| return cb(null, linkTarget) | |
| } | |
| } | |
| origReadlink(...args) | |
| } | |
| fs.readlinkSync = readlinkSync | |
| function readlinkSync(...args: Parameters<typeof origReadlinkSync>) { | |
| const resolved = resolvePathLike(args[0]) | |
| const linkTarget = origReadlinkSync(...args) | |
| const targetAbs = resolveTargetAgainstRealParent(resolved, linkTarget) | |
| const escapedRoot: string | false = isEscape(resolved, targetAbs) | |
| if (escapedRoot) { | |
| const next = nextHopSync(targetAbs) | |
| if (!next) { | |
| if (next == undefined) { | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| throw enoent('readlink', args[0]) | |
| } else { | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| throw einval('readlink', args[0]) | |
| } | |
| } | |
| const r = path.resolve( | |
| path.dirname(resolved), | |
| path.relative(path.dirname(targetAbs), next) | |
| ) | |
| if ( | |
| r != resolved && | |
| !isEscape(resolved, r, [escapedRoot]) && | |
| hasSameTarget(resolved, r) | |
| ) { | |
| if (path.isAbsolute(linkTarget)) { | |
| return r | |
| } | |
| const rel = path.relative(path.dirname(resolved), r) | |
| return rel || '.' | |
| } | |
| // The escape from the root is not mappable back into the root; throw EINVAL | |
| throw einval('readlink', args[0]) | |
| } | |
| return linkTarget | |
| } | |
| // ========================================================================= | |
| // fs.readdir | |
| // ========================================================================= | |
| fs.readdir = function readdir(...args: Parameters<typeof origReaddir>) { | |
| // preserve error when calling function without required callback | |
| if (typeof args[args.length - 1] !== 'function') { | |
| return origReaddir(...args) | |
| } | |
| const cb = once(args[args.length - 1] as Function) | |
| const p = resolvePathLike(args[0]) | |
| args[args.length - 1] = function readdirCb( | |
| err: Error | null, | |
| result: Array<Dirent<string | Buffer>> | |
| ) { | |
| if (err) return cb(err) | |
| // user requested withFileTypes | |
| if (result[0] && (result[0] as any).isSymbolicLink) { | |
| Promise.all( | |
| result.map((v: Dirent<string | Buffer>) => | |
| handleDirent(p, v) | |
| ) | |
| ) | |
| .then(() => { | |
| cb(null, result) | |
| }) | |
| .catch((err) => { | |
| cb(err) | |
| }) | |
| } else { | |
| // string array return for readdir. | |
| cb(null, result) | |
| } | |
| } | |
| origReaddir(...args) | |
| } | |
| fs.readdirSync = function readdirSync( | |
| ...args: Parameters<typeof origReaddirSync> | |
| ) { | |
| const res = origReaddirSync(...args) | |
| const p = resolvePathLike(args[0]) | |
| res.forEach((v: Dirent | any) => { | |
| handleDirentSync(p, v) | |
| }) | |
| return res | |
| } | |
| // ========================================================================= | |
| // fs.opendir | |
| // ========================================================================= | |
| if (fs.opendir) { | |
| const origOpendir = fs.opendir.bind(fs) | |
| fs.opendir = function opendir(...args: Parameters<typeof origOpendir>) { | |
| // if this is not a function opendir should throw an error. | |
| // we call it so we don't have to throw a mock | |
| if (typeof args[args.length - 1] === 'function') { | |
| const cb = once(args[args.length - 1] as Function) | |
| args[args.length - 1] = async function opendirCb( | |
| err: Error | null, | |
| dir: Dir | |
| ) { | |
| try { | |
| cb(err, err ? undefined : handleDir(dir)) | |
| } catch (err) { | |
| cb(err) | |
| } | |
| } | |
| origOpendir(...args) | |
| } else { | |
| return origOpendir(...args).then(handleDir) | |
| } | |
| } | |
| } | |
| if (fs.opendirSync) { | |
| const origOpendirSync = fs.opendirSync.bind(fs) | |
| fs.opendirSync = function opendirSync( | |
| ...args: Parameters<typeof origOpendirSync> | |
| ) { | |
| const dir = origOpendirSync(...args) | |
| return handleDir(dir) | |
| } | |
| } | |
| // ========================================================================= | |
| // fs.promises | |
| // ========================================================================= | |
| /** | |
| * patch fs.promises here. | |
| * | |
| * this requires a light touch because if we trigger the getter on older nodejs versions | |
| * it will log an experimental warning to stderr | |
| * | |
| * `(node:62945) ExperimentalWarning: The fs.promises API is experimental` | |
| * | |
| * this api is available as experimental without a flag so users can access it at any time. | |
| */ | |
| const promisePropertyDescriptor = Object.getOwnPropertyDescriptor( | |
| fs, | |
| 'promises' | |
| ) | |
| let unpatchPromises: Function | |
| if (promisePropertyDescriptor) { | |
| const promises: typeof fs.promises = {} | |
| promises.lstat = util.promisify(fs.lstat) | |
| // NOTE: node core uses the newer realpath function fs.promises.native instead of fs.realPath | |
| promises.realpath = util.promisify(fs.realpath.native) | |
| promises.readlink = util.promisify(fs.readlink) | |
| promises.readdir = util.promisify(fs.readdir) | |
| if (fs.opendir) promises.opendir = util.promisify(fs.opendir) | |
| // handle experimental api warnings. | |
| // only applies to version of node where promises is a getter property. | |
| if (promisePropertyDescriptor.get) { | |
| const oldGetter = promisePropertyDescriptor.get.bind(fs) | |
| const cachedPromises = {} | |
| function promisePropertyGetter() { | |
| const _promises = oldGetter() | |
| Object.assign(cachedPromises, _promises, promises) | |
| return cachedPromises | |
| } | |
| Object.defineProperty( | |
| fs, | |
| 'promises', | |
| Object.assign(Object.create(promisePropertyDescriptor), { | |
| get: promisePropertyGetter, | |
| }) | |
| ) | |
| unpatchPromises = function unpatchFsPromises() { | |
| Object.defineProperty(fs, 'promises', promisePropertyDescriptor) | |
| } | |
| } else { | |
| const unpatchedPromises = Object.keys(promises).reduce( | |
| (obj, method) => { | |
| obj[method] = fs.promises[method] | |
| return obj | |
| }, | |
| Object.create(fs.promises) | |
| ) | |
| // api can be patched directly | |
| Object.assign(fs.promises, promises) | |
| unpatchPromises = function unpatchFsPromises() { | |
| Object.assign(fs.promises, unpatchedPromises) | |
| } | |
| } | |
| } | |
| // ========================================================================= | |
| // helper functions for dirs | |
| // ========================================================================= | |
| function handleDir(dir: Dir) { | |
| const p = path.resolve(dir.path) | |
| const origIterator = dir[Symbol.asyncIterator].bind(dir) | |
| dir[Symbol.asyncIterator] = async function* () { | |
| for await (const entry of origIterator()) { | |
| await handleDirent(p, entry) | |
| yield entry | |
| } | |
| } | |
| const origRead = dir.read.bind(dir) | |
| dir.read = function readWrapper() { | |
| if (typeof arguments[0] === 'function') { | |
| return handleDirReadCallback(arguments[0]) | |
| } else { | |
| return handleDirReadPromise() | |
| } | |
| } as any | |
| // read(cb: (err: NodeJS.ErrnoException | null, dirEnt: Dirent | null) => void): void; | |
| function handleDirReadCallback( | |
| cb: (err: Error | null, entry: Dirent | null) => void | |
| ): void { | |
| origRead(function handleDirReadCb( | |
| err: Error | null, | |
| entry: Dirent | null | |
| ) { | |
| if (err) return cb(err, null) | |
| handleDirent(p, entry!).then( | |
| () => { | |
| cb(null, entry!) | |
| }, | |
| (err) => cb(err, null) | |
| ) | |
| }) | |
| } | |
| // read(): Promise<Dirent | null>; | |
| async function handleDirReadPromise(): Promise<Dirent | null> { | |
| const entry = await origRead() | |
| if (entry) { | |
| await handleDirent(p, entry) | |
| } | |
| return entry | |
| } | |
| const origReadSync = dir.readSync.bind(dir) | |
| dir.readSync = function handleDirReadSync() { | |
| return handleDirentSync(p, origReadSync()) | |
| } | |
| return dir | |
| } | |
| async function handleDirent(p: string, v: Dirent<string | Buffer>) { | |
| if (!v.isSymbolicLink()) { | |
| return v | |
| } | |
| const entryName = | |
| typeof v.name === 'string' ? v.name : v.name.toString() | |
| const f = path.resolve(p, entryName) | |
| return new Promise(function handleDirentExecutor(resolve, reject) { | |
| return guardedReadLink(f, handleDirentReadLinkCb) | |
| function handleDirentReadLinkCb(str: string) { | |
| if (f != str) { | |
| return resolve(v) | |
| } | |
| // There are no hops so we should hide the fact that the file is a symlink | |
| v.isSymbolicLink = () => false | |
| origRealpath(f, function handleDirentRealpathCb(err, str) { | |
| if (err) { | |
| return reject(err) | |
| } | |
| fs.stat( | |
| str, | |
| function handleDirentStatCb( | |
| err: Error | null, | |
| stat: Stats | undefined | |
| ) { | |
| if (err) { | |
| return reject(err) | |
| } | |
| patchDirent(v, stat) | |
| resolve(v) | |
| } | |
| ) | |
| }) | |
| } | |
| }) | |
| } | |
| function handleDirentSync(p: string, v: Dirent | null): Dirent | null { | |
| if (v && v.isSymbolicLink) { | |
| if (v.isSymbolicLink()) { | |
| const f = path.resolve(p, v.name) | |
| if (f == guardedReadLinkSync(f)) { | |
| // There are no hops so we should hide the fact that the file is a symlink | |
| v.isSymbolicLink = () => false | |
| const stat = fs.statSync(origRealpathSync(f)) | |
| patchDirent(v, stat) | |
| } | |
| } | |
| } | |
| return v | |
| } | |
| function nextHop( | |
| loc: string, | |
| cb: (next: string | undefined | false) => void | |
| ): void { | |
| let nested = '' | |
| let maybe = loc | |
| let escapedHop: string | false = false | |
| readHopLink(maybe, function readNextHop(link) { | |
| if (link === HOP_NOT_FOUND) { | |
| return cb(undefined) | |
| } | |
| if (link !== HOP_NON_LINK) { | |
| if (nested) { | |
| link = link + path.sep + nested | |
| } | |
| if (!isEscape(loc, link)) { | |
| return cb(link) | |
| } | |
| if (!escapedHop) { | |
| escapedHop = link | |
| } | |
| } | |
| const dirname = path.dirname(maybe) | |
| if ( | |
| !dirname || | |
| dirname == maybe || | |
| dirname == '.' || | |
| dirname == '/' | |
| ) { | |
| // not a link | |
| return cb(escapedHop) | |
| } | |
| nested = path.basename(maybe) + (nested ? path.sep + nested : '') | |
| maybe = dirname | |
| readHopLink(maybe, readNextHop) | |
| }) | |
| } | |
| const symlinkNoThrow: StatSyncOptions = Object.freeze({ | |
| throwIfNoEntry: false, | |
| }) | |
| const hopLinkCache = Object.create(null) as { [f: string]: HopResults } | |
| function readHopLinkSync(p: string): HopResults { | |
| if (hopLinkCache[p]) { | |
| return hopLinkCache[p] | |
| } | |
| let link: HopResults | |
| const pStats = origLstatSync(p, symlinkNoThrow) | |
| if (!pStats) { | |
| link = HOP_NOT_FOUND | |
| } else if (pStats.isSymbolicLink()) { | |
| link = origReadlinkSync(p) as string | |
| if (link) { | |
| if (!path.isAbsolute(link)) { | |
| link = path.resolve(path.dirname(p), link) | |
| } | |
| } else { | |
| link = HOP_NON_LINK | |
| } | |
| } else { | |
| link = HOP_NON_LINK | |
| } | |
| hopLinkCache[p] = link | |
| return link | |
| } | |
| function readHopLink(p: string, cb: (l: HopResults) => void) { | |
| if (hopLinkCache[p]) { | |
| return cb(hopLinkCache[p]) | |
| } | |
| origReadlink( | |
| p, | |
| function readHopLinkCb(err: Error | null, link: string) { | |
| if (err) { | |
| let result: HopResults | |
| if ((err as any).code === 'ENOENT') { | |
| // file does not exist | |
| result = HOP_NOT_FOUND | |
| } else { | |
| result = HOP_NON_LINK | |
| } | |
| hopLinkCache[p] = result | |
| return cb(result) | |
| } | |
| if (link === undefined) { | |
| hopLinkCache[p] = HOP_NON_LINK | |
| return cb(HOP_NON_LINK) | |
| } | |
| if (!path.isAbsolute(link)) { | |
| link = path.resolve(path.dirname(p), link) | |
| } | |
| hopLinkCache[p] = link | |
| cb(link) | |
| } | |
| ) | |
| } | |
| function nextHopSync(loc: string): string | false { | |
| let nested = '' | |
| let maybe = loc | |
| let escapedHop: string | false = false | |
| for (;;) { | |
| let link = readHopLinkSync(maybe) | |
| if (link === HOP_NOT_FOUND) { | |
| return false | |
| } | |
| if (link !== HOP_NON_LINK) { | |
| if (nested) { | |
| link = link + path.sep + nested | |
| } | |
| if (!isEscape(loc, link)) { | |
| return link | |
| } | |
| if (!escapedHop) { | |
| escapedHop = link | |
| } | |
| } | |
| const dirname = path.dirname(maybe) | |
| if ( | |
| !dirname || | |
| dirname == maybe || | |
| dirname == '.' || | |
| dirname == '/' | |
| ) { | |
| // not a link | |
| return escapedHop | |
| } | |
| nested = path.basename(maybe) + (nested ? path.sep + nested : '') | |
| maybe = dirname | |
| } | |
| } | |
| function guardedReadLink(loc: string, cb: (str: string) => void): void { | |
| nextHop(loc, guardedReadLinkHopCb) | |
| function guardedReadLinkHopCb(next: string | undefined | false) { | |
| if (!next) { | |
| // we're no longer hopping but we haven't escaped; | |
| // something funky happened in the filesystem | |
| return cb(loc) | |
| } | |
| if (isEscape(loc, next)) { | |
| // this hop takes us out of the guard | |
| return cb(loc) | |
| } | |
| return cb(next) | |
| } | |
| } | |
| function guardedReadLinkSync(loc: string): string { | |
| const next = nextHopSync(loc) | |
| if (!next) { | |
| // we're no longer hopping but we haven't escaped; | |
| // something funky happened in the filesystem | |
| return loc | |
| } | |
| if (isEscape(loc, next)) { | |
| // this hop takes us out of the guard | |
| return loc | |
| } | |
| return next | |
| } | |
| function guardedRealPath( | |
| start: PathLike, | |
| cb: ErrPathCallback, | |
| escapedRoots: string[] | |
| ): void { | |
| // stringifyPathLike() to handle the "undefined" case (matches behavior as fs.realpath) | |
| oneHop(stringifyPathLike(start), cb) | |
| function oneHop(loc: string, cb: ErrPathCallback) { | |
| nextHop(loc, function guardedRealPathHopCb(next) { | |
| if (!next) { | |
| // we're no longer hopping but we haven't escaped | |
| return fs.exists( | |
| loc, | |
| function guardedRealPathExistsCb(e: boolean) { | |
| if (e) { | |
| // we hit a real file within the guard and can go no further | |
| return cb(null, loc) | |
| } else { | |
| // something funky happened in the filesystem | |
| return cb(enoent('realpath', start), undefined) | |
| } | |
| } | |
| ) | |
| } | |
| if (isEscape(loc, next, escapedRoots)) { | |
| // this hop takes us out of the guard | |
| return cb(null, loc) | |
| } | |
| oneHop(next, cb) | |
| }) | |
| } | |
| } | |
| function guardedRealPathSync( | |
| start: PathLike, | |
| escapedRoots: string[] | |
| ): string { | |
| // stringifyPathLike() to handle the "undefined" case (matches behavior as fs.realpathSync) | |
| for ( | |
| let loc = stringifyPathLike(start), next: string | false; | |
| ; | |
| loc = next as string | |
| ) { | |
| next = nextHopSync(loc) | |
| if (!next) { | |
| // we're no longer hopping but we haven't escaped | |
| if (fs.existsSync(loc)) { | |
| // we hit a real file within the guard and can go no further | |
| return loc | |
| } else { | |
| // something funky happened in the filesystem; throw ENOENT | |
| throw enoent('realpath', start) | |
| } | |
| } | |
| if (isEscape(loc, next, escapedRoots)) { | |
| // this hop takes us out of the guard | |
| return loc | |
| } | |
| } | |
| } | |
| // Sync the esm modules to use the now patched fs cjs module. | |
| // See: https://nodejs.org/api/esm.html#builtin-modules | |
| esmModule.syncBuiltinESMExports() | |
| return function revertPatch() { | |
| Object.assign(fs, fs._unpatched) | |
| delete fs._unpatched | |
| if (unpatchPromises) { | |
| unpatchPromises() | |
| } | |
| // Re-sync the esm modules to revert to the unpatched module. | |
| esmModule.syncBuiltinESMExports() | |
| } | |
| } | |
| // ========================================================================= | |
| // generic helper functions | |
| // ========================================================================= | |
| export function isSubPath(parent: string, child: string): boolean { | |
| return ( | |
| parent === child || | |
| (child[parent.length] === path.sep && child.startsWith(parent)) | |
| ) | |
| } | |
| function stringifyPathLike(p: PathLike): string { | |
| if (p instanceof URL) { | |
| return url.fileURLToPath(p) | |
| } else { | |
| return String(p) | |
| } | |
| } | |
| function resolvePathLike(p: PathLike): string { | |
| return path.resolve(stringifyPathLike(p)) | |
| } | |
| function normalizePathLike(p: PathLike): string { | |
| const s = stringifyPathLike(p) | |
| // TODO: are URLs always absolute? | |
| if (!path.isAbsolute(s)) { | |
| return path.resolve(s) | |
| } else { | |
| return path.normalize(s) | |
| } | |
| } | |
| export function escapeFunction(_roots: string[]) { | |
| // Ensure roots are always absolute. | |
| // Sort to ensure escaping multiple roots chooses the longest one. | |
| const defaultRoots = _roots | |
| .map((root) => path.resolve(root)) | |
| .sort((a, b) => b.length - a.length) | |
| function fs_isEscape( | |
| linkPath: PathLike, | |
| linkTarget: string, | |
| roots = defaultRoots | |
| ): false | string { | |
| // linkPath is the path of the symlink file itself | |
| // linkTarget is a path that the symlink points to one or more hops away | |
| // linkTarget must already be normalized | |
| linkPath = normalizePathLike(linkPath) | |
| for (const root of roots) { | |
| // If the link is in the root check if the realPath has escaped | |
| if (isSubPath(root, linkPath) && !isSubPath(root, linkTarget)) { | |
| return root | |
| } | |
| } | |
| return false | |
| } | |
| function fs_canEscape( | |
| maybeLinkPath: string, | |
| roots = defaultRoots | |
| ): boolean { | |
| // maybeLinkPath is the path which may be a symlink | |
| // maybeLinkPath must already be normalized | |
| for (const root of roots) { | |
| // If the link is in the root check if the realPath has escaped | |
| if (isSubPath(root, maybeLinkPath)) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| return { | |
| isEscape: fs_isEscape, | |
| canEscape: fs_canEscape, | |
| } | |
| } | |
| function once(fn: Function) { | |
| let called = false | |
| return function callOnce(...args: any[]) { | |
| if (called) return | |
| called = true | |
| let err: Error | false = false | |
| try { | |
| fn(...args) | |
| } catch (_e: any) { | |
| err = _e | |
| } | |
| // blow the stack to make sure this doesn't fall into any unresolved promise contexts | |
| if (err) { | |
| setImmediate(() => { | |
| throw err | |
| }) | |
| } | |
| } | |
| } | |
| function patchDirent(dirent: Dirent | any, stat: Stats | any): void { | |
| // add all stat is methods to Dirent instances with their result. | |
| for (const i in stat) { | |
| if (i.startsWith('is') && typeof stat[i] === 'function') { | |
| // | |
| const result = stat[i]() | |
| if (result) dirent[i] = () => true | |
| else dirent[i] = () => false | |
| } | |
| } | |
| } | |
| function enoent(s: string, p: PathLike): Error { | |
| let err = new Error(`ENOENT: no such file or directory, ${s} '${p}'`) | |
| ;(err as any).errno = -2 | |
| ;(err as any).syscall = s | |
| ;(err as any).code = 'ENOENT' | |
| ;(err as any).path = p | |
| return err | |
| } | |
| function einval(s: string, p: PathLike): Error { | |
| let err = new Error(`EINVAL: invalid argument, ${s} '${p}'`) | |
| ;(err as any).errno = -22 | |
| ;(err as any).syscall = s | |
| ;(err as any).code = 'EINVAL' | |
| ;(err as any).path = p | |
| return err | |
| } |