blob: 1db0d0ac1be45fbe6e9ed2e5da6c99e67fece051 [file]
/**
* @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 { Stats } from 'fs'
import * as path from 'path'
import * as util from 'util'
// windows cant find the right types
type Dir = any
type Dirent = any
// 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('fs')
export const patcher = (fs: any = _fs, roots: string[]) => {
fs = fs || _fs
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
}
const origLstat = fs.lstat.bind(fs)
const origLstatSync = fs.lstatSync.bind(fs)
const origReaddir = fs.readdir.bind(fs)
const origReaddirSync = fs.readdirSync.bind(fs)
const origReadlink = fs.readlink.bind(fs)
const origReadlinkSync = fs.readlinkSync.bind(fs)
const origRealpath = fs.realpath.bind(fs)
const origRealpathNative = fs.realpath.native
const origRealpathSync = fs.realpathSync.bind(fs)
const origRealpathSyncNative = fs.realpathSync.native
const isEscape = escapeFunction(roots)
// =========================================================================
// fs.lstat
// =========================================================================
fs.lstat = (...args: any[]) => {
let cb = args.length > 1 ? args[args.length - 1] : undefined
// preserve error when calling function without required callback
if (!cb) {
return origLstat(...args)
}
cb = once(cb)
// override the callback
args[args.length - 1] = (err: Error, stats: Stats) => {
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] = path.resolve(args[0])
return guardedReadLink(args[0], (str: string) => {
if (str != args[0]) {
// there are one or more hops within the guards so there is nothing more to do
return cb(null, stats)
}
// there are no hops so lets report the stats of the real file;
// we can't use origRealPath here since that function calls lstat internally
// which can result in an infinite loop
return unguardedRealPath(args[0], (err: Error, str: string) => {
if (err) {
if ((err as any).code === 'ENOENT') {
// broken link so there is nothing more to do
return cb(null, stats)
}
return cb(err)
}
return origLstat(str, (err, str) => cb(err, str))
})
})
}
origLstat(...args)
}
fs.lstatSync = (...args: any[]) => {
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] = path.resolve(args[0])
const guardedReadLink: string = guardedReadLinkSync(args[0])
if (guardedReadLink != args[0]) {
// there are one or more hops within the guards so there is nothing more to do
return stats
}
try {
// there are no hops so lets report the stats of the real file;
// we can't use origRealPathSync here since that function calls lstat internally
// which can result in an infinite loop
return origLstatSync(unguardedRealPathSync(args[0]), args.slice(1))
} catch (err) {
if (err.code === 'ENOENT') {
// broken link so there is nothing more to do
return stats
}
throw err
}
}
// =========================================================================
// fs.realpath
// =========================================================================
fs.realpath = (...args: any[]) => {
let cb = args.length > 1 ? args[args.length - 1] : undefined
// preserve error when calling function without required callback
if (!cb) {
return origRealpath(...args)
}
cb = once(cb)
args[args.length - 1] = (err: Error, str: string) => {
if (err) return cb(err)
const escapedRoot: string | false = isEscape(args[0], str)
if (escapedRoot) {
return guardedRealPath(
args[0],
(err, str) => cb(err, str),
escapedRoot
)
} else {
return cb(null, str)
}
}
origRealpath(...args)
}
fs.realpath.native = (...args: any[]) => {
let cb = args.length > 1 ? args[args.length - 1] : undefined
// preserve error when calling function without required callback
if (!cb) {
return origRealpathNative(...args)
}
cb = once(cb)
args[args.length - 1] = (err: Error, str: string) => {
if (err) return cb(err)
const escapedRoot: string | false = isEscape(args[0], str)
if (escapedRoot) {
return guardedRealPath(
args[0],
(err, str) => cb(err, str),
escapedRoot
)
} else {
return cb(null, str)
}
}
origRealpathNative(...args)
}
fs.realpathSync = (...args: any[]) => {
const str = origRealpathSync(...args)
const escapedRoot: string | false = isEscape(args[0], str)
if (escapedRoot) {
return guardedRealPathSync(args[0], escapedRoot)
}
return str
}
fs.realpathSync.native = (...args: any[]) => {
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 = (...args: any[]) => {
let cb = args.length > 1 ? args[args.length - 1] : undefined
// preserve error when calling function without required callback
if (!cb) {
return origReadlink(...args)
}
cb = once(cb)
args[args.length - 1] = (err: Error, str: string) => {
if (err) return cb(err)
const resolved = path.resolve(args[0])
str = path.resolve(path.dirname(resolved), str)
const escapedRoot: string | false = isEscape(resolved, str)
if (escapedRoot) {
return nextHop(str, (next: string | 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]))
}
}
next = path.resolve(
path.dirname(resolved),
path.relative(path.dirname(str), next)
)
if (
next != resolved &&
!isEscape(resolved, next, [escapedRoot])
) {
return cb(null, next)
}
// The escape from the root is not mappable back into the root; we must make
// this look like a real file so we call readlink on the realpath which we
// expect to return an error
return origRealpath(resolved, (err, str) => {
if (err) return cb(err)
return origReadlink(str, (err, str) => cb(err, str))
})
})
} else {
return cb(null, str)
}
}
origReadlink(...args)
}
fs.readlinkSync = (...args: any[]) => {
const resolved = path.resolve(args[0])
const str = path.resolve(
path.dirname(resolved),
origReadlinkSync(...args)
)
const escapedRoot: string | false = isEscape(resolved, str)
if (escapedRoot) {
let next: string | false = nextHopSync(str)
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])
}
}
next = path.resolve(
path.dirname(resolved),
path.relative(path.dirname(str), next)
)
if (next != resolved && !isEscape(resolved, next, [escapedRoot])) {
return next
}
// The escape from the root is not mappable back into the root; throw EINVAL
throw einval('readlink', args[0])
}
return str
}
// =========================================================================
// fs.readdir
// =========================================================================
fs.readdir = (...args: any[]) => {
const p = path.resolve(args[0])
let cb = args[args.length - 1]
if (typeof cb !== 'function') {
// this will likely throw callback required error.
return origReaddir(...args)
}
cb = once(cb)
args[args.length - 1] = (err: Error, result: Dirent[]) => {
if (err) return cb(err)
// user requested withFileTypes
if (result[0] && result[0].isSymbolicLink) {
Promise.all(result.map((v: Dirent) => handleDirent(p, v)))
.then(() => {
cb(null, result)
})
.catch((err) => {
cb(err)
})
} else {
// string array return for readdir.
cb(null, result)
}
}
origReaddir(...args)
}
fs.readdirSync = (...args: any[]) => {
const res = origReaddirSync(...args)
const p = path.resolve(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 = (...args: any[]) => {
let cb = args[args.length - 1]
// 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 cb === 'function') {
cb = once(cb)
args[args.length - 1] = async (err: Error, dir: Dir) => {
try {
cb(null, await handleDir(dir))
} catch (err) {
cb(err)
}
}
origOpendir(...args)
} else {
return origOpendir(...args).then((dir: Dir) => {
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'
)
if (promisePropertyDescriptor) {
const promises: any = {}
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 = {}
promisePropertyDescriptor.get = () => {
const _promises = oldGetter()
Object.assign(cachedPromises, _promises, promises)
return cachedPromises
}
Object.defineProperty(fs, 'promises', promisePropertyDescriptor)
} else {
// api can be patched directly
Object.assign(fs.promises, promises)
}
}
// =========================================================================
// helper functions for dirs
// =========================================================================
async function handleDir(dir: Dir) {
const p = path.resolve(dir.path)
const origIterator = dir[Symbol.asyncIterator].bind(dir)
const origRead: any = dir.read.bind(dir)
dir[Symbol.asyncIterator] = async function* () {
for await (const entry of origIterator()) {
await handleDirent(p, entry)
yield entry
}
}
;(dir.read as any) = async (...args: any[]) => {
if (typeof args[args.length - 1] === 'function') {
const cb = args[args.length - 1]
args[args.length - 1] = async (err: Error, entry: Dirent) => {
cb(err, entry ? await handleDirent(p, entry) : null)
}
origRead(...args)
} else {
const entry = await origRead(...args)
if (entry) {
await handleDirent(p, entry)
}
return entry
}
}
const origReadSync: any = dir.readSync.bind(dir)
;(dir.readSync as any) = () => {
return handleDirentSync(p, origReadSync()) // intentionally sync for simplicity
}
return dir
}
function handleDirent(p: string, v: Dirent): Promise<Dirent> {
return new Promise((resolve, reject) => {
if (!v.isSymbolicLink()) {
return resolve(v)
}
const f = path.resolve(p, v.name)
return guardedReadLink(f, (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, (err, str) => {
if (err) {
throw err
}
fs.stat(str, (err, stat) => {
if (err) {
throw err
}
patchDirent(v, stat)
resolve(v)
})
})
})
})
}
function handleDirentSync(p: string, v: Dirent | null): void {
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)
}
}
}
}
function nextHop(loc: string, cb: (next: string | false) => void): void {
let nested: string[] = []
let escapedHop: string | false = false
const oneHop = (maybe, cb: (next: string | false) => void) => {
origReadlink(maybe, (err: Error, str: string) => {
if (err) {
if ((err as any).code === 'ENOENT') {
// file does not exist
return cb(undefined)
}
nested.push(path.basename(maybe))
const dirname = path.dirname(maybe)
if (
!dirname ||
dirname == maybe ||
dirname == '.' ||
dirname == '/'
) {
// not a link
return cb(escapedHop)
}
maybe = dirname
return oneHop(maybe, cb)
}
if (!path.isAbsolute(str)) {
str = path.resolve(path.dirname(maybe), str)
}
str = path.join(str, ...nested.reverse())
if (isEscape(loc, str)) {
if (!escapedHop) {
escapedHop = str
}
nested.push(path.basename(maybe))
const dirname = path.dirname(maybe)
if (
!dirname ||
dirname == maybe ||
dirname == '.' ||
dirname == '/'
) {
// not a link
return cb(escapedHop)
}
maybe = dirname
return oneHop(maybe, cb)
} else {
return cb(str)
}
})
}
oneHop(loc, cb)
}
function nextHopSync(loc: string): string | false {
let readlink: string
let nested: string[] = []
let maybe = loc
let escapedHop: string | false = false
function _tryParent(): boolean {
nested.push(path.basename(maybe))
const dirname = path.dirname(maybe)
if (
!dirname ||
dirname == maybe ||
dirname == '.' ||
dirname == '/'
) {
// not a link
return false
}
maybe = dirname
return true
}
for (;;) {
try {
readlink = origReadlinkSync(maybe)
if (!path.isAbsolute(readlink)) {
readlink = path.resolve(path.dirname(maybe), readlink)
}
readlink = path.join(readlink, ...nested.reverse())
if (isEscape(loc, readlink)) {
if (!escapedHop) {
escapedHop = readlink
}
if (!_tryParent()) {
return escapedHop
}
continue
}
return readlink
} catch (err) {
if (err.code === 'ENOENT') {
// file does not exist
return undefined
}
if (!_tryParent()) {
return escapedHop
}
continue
}
}
}
function guardedReadLink(start: string, cb: (str: string) => void): void {
let loc = start
return nextHop(loc, (next: string | 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 nextHop(next, (next2: string | false) => {
if (!next2) {
// the chain is done
return cb(loc)
}
const maybe = path.resolve(
path.dirname(loc),
path.relative(path.dirname(next), next2)
)
if (!isEscape(loc, maybe)) {
// outside of the guard is a symlink but it is a relative link path
// we can map within the guard so return that
return cb(maybe)
}
// outside of the guard is a symlink that is not mappable inside the guard
return cb(loc)
})
}
return cb(next)
})
}
function guardedReadLinkSync(start: string): string {
let loc = start
let next: string | false = 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
const next2: string | false = nextHopSync(next)
if (!next2) {
// the chain is done
return loc
}
const maybe = path.resolve(
path.dirname(loc),
path.relative(path.dirname(next), next2)
)
if (!isEscape(loc, maybe)) {
// outside of the guard is a symlink but it is a relative link path
// we can map within the guard so return that
return maybe
}
// outside of the guard is a symlink that is not mappable inside the guard
return loc
}
return next
}
function unguardedRealPath(
start: string,
cb: (err: Error, str?: string) => void
): void {
start = String(start) // handle the "undefined" case (matches behavior as fs.realpath)
const oneHop = (loc, cb) => {
nextHop(loc, (next) => {
if (next == undefined) {
// file does not exist (broken link)
return cb(enoent('realpath', start))
} else if (!next) {
// we've hit a real file
return cb(null, loc)
}
oneHop(next, cb)
})
}
oneHop(start, cb)
}
function guardedRealPath(
start: string,
cb: (err: Error, str?: string) => void,
escapedRoot: string = undefined
): void {
start = String(start) // handle the "undefined" case (matches behavior as fs.realpath)
const oneHop = (
loc: string,
cb: (err: Error, str?: string) => void
) => {
nextHop(loc, (next) => {
if (!next) {
// we're no longer hopping but we haven't escaped
return fs.exists(loc, (e) => {
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))
}
})
}
if (
escapedRoot
? isEscape(loc, next, [escapedRoot])
: isEscape(loc, next)
) {
// this hop takes us out of the guard
return nextHop(next, (next2) => {
if (!next2) {
// the chain is done
return cb(null, loc)
}
const maybe = path.resolve(
path.dirname(loc),
path.relative(path.dirname(next), next2)
)
if (isEscape(loc, maybe)) {
// outside of the guard is a symlink that is not mappable inside the guard;
// call the unguarded realpath which will throw if the link is dangling;
// if it doesn't throw then return the last path within the guard
return origRealpath(start, (err, _) => {
if (err) return cb(err)
return cb(null, loc)
})
}
return oneHop(maybe, cb)
})
}
oneHop(next, cb)
})
}
oneHop(start, cb)
}
function unguardedRealPathSync(start: string): string {
start = String(start) // handle the "undefined" case (matches behavior as fs.realpathSync)
for (let loc = start, next; ; loc = next) {
next = nextHopSync(loc)
if (next == undefined) {
// file does not exist (broken link)
throw enoent('realpath', start)
} else if (!next) {
// we've hit a real file
return loc
}
}
}
function guardedRealPathSync(
start: string,
escapedRoot: string = undefined
): string {
start = String(start) // handle the "undefined" case (matches behavior as fs.realpathSync)
for (let loc = 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 (
escapedRoot
? isEscape(loc, next, [escapedRoot])
: isEscape(loc, next)
) {
// this hop takes us out of the guard
const next2: string | false = nextHopSync(next)
if (!next2) {
// the chain is done
return loc
}
const maybe = path.resolve(
path.dirname(loc),
path.relative(path.dirname(next), next2)
)
if (isEscape(loc, maybe)) {
// outside of the guard is a symlink that is not mappable inside the guard;
// call the unguarded realpath which will throw if the link is dangling;
// if it doesn't throw then return the last path within the guard
origRealpathSync(start)
return loc
}
next = maybe
// outside of the guard is a symlink but it is a relative link path
// we can map within the guard so lets iterate one more time
}
}
}
}
// =========================================================================
// generic helper functions
// =========================================================================
export function isSubPath(parent: string, child: string): boolean {
return !path.relative(parent, child).startsWith('..')
}
export const escapeFunction = (_roots: string[]) => {
// ensure roots are always absolute
_roots = _roots.map((root) => path.resolve(root))
function _isEscape(
linkPath: string,
linkTarget: string,
roots = _roots
): 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
if (!path.isAbsolute(linkPath)) {
linkPath = path.resolve(linkPath)
}
if (!path.isAbsolute(linkTarget)) {
linkTarget = path.resolve(linkTarget)
}
let escapedRoot = undefined
for (const root of roots) {
// If the link is in the root check if the realPath has escaped
if (isSubPath(root, linkPath) || linkPath == root) {
if (!isSubPath(root, linkTarget) && linkTarget != root) {
if (!escapedRoot || escapedRoot.length < root.length) {
// if escaping multiple roots then choose the longest one
escapedRoot = root
}
}
}
}
if (escapedRoot) {
return escapedRoot
}
return false
}
return _isEscape
}
function once<T>(fn: (...args: unknown[]) => T) {
let called = false
return (...args: unknown[]) => {
if (called) return
called = true
let err: Error | false = false
try {
fn(...args)
} catch (_e) {
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.indexOf('is') === 0 && typeof stat[i] === 'function') {
//
const result = stat[i]()
if (result) dirent[i] = () => true
else dirent[i] = () => false
}
}
}
function enoent(s: string, p: string): 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: string): 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
}